Sleazy Fork is available in English.

Brazen Framework - Framework

Main class of the Brazen user scripts framework

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Brazen Framework - Framework
// @namespace    brazenvoid
// @version      14.1.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-14.1.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 OPTION_AUTO_HIDE_SETTINGS_PANE = 'auto-hide-settings-pane'
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'

const OPTION_ENABLE_EXPLORED_TAGS_TRACKER = 'enable-explored-tags-tracker'

const OPTION_HIDE_OLDER_POSTS = 'hide-older-posts'
const OPTION_LAST_ID = 'last-id'
const OPTION_AUTO_NEXT_PAGE = 'auto-next-page'
const DOCK_SET_LATEST_ID = 'set-latest-id'
const DOCK_SET_POST_ID = 'set-post-id'

/** Tiles processed per compliance batch before yielding to the event loop. */
const COMPLIANCE_TILE_BATCH_SIZE = 10

/**
 * Default detailed help HTML for framework-owned settings fields.
 * Consumer scripts may override per field via {@link ConfigurationField#setHelp}.
 *
 * @type {Readonly<Record<string, string>>}
 */
const FRAMEWORK_FIELD_DETAILED_HELP = Object.freeze({
  DISABLE_ALL_FILTERS:
      '<p>Dock master switch to bypass every filter without clearing your settings. Hover it for individual filter toggles.</p>' +
      '<p>When bypassed, blacklists, explored tags, hide older posts, and hide downloaded no longer remove tiles on search pages.</p>',

  BOOKMARKS:
      '<p>Save searches as one-click bookmarks from the dock star on search pages or from sidebar tag rows.</p>' +
      '<p>Bookmarks store the tags you searched for — not script default tags or width/height filters. ' +
      'Manage saved searches here: filter the list, sort A–Z with ⇅, remove with ×. ' +
      'Single-tag bookmarks may show eye or compass buttons when that tag is on your hide or explore list.</p>' +
      '<p>Included in backup/restore (bookmark list is replaced on restore).</p>',

  ENABLE_TAG_BLACKLIST:
      '<p>Also toggle from the dock. When enabled, tag blacklist rules hide matching posts on search pages.</p>' +
      '<p>Hover a sidebar tag row and click the eye to add a tag here and turn this on. Click again ' +
      '(<strong>Stop hiding posts with this tag</strong>) to remove it. A strike on the eye means it is active.</p>' +
      '<p>Edit bulk or combined rules in the Tag Blacklist panel below; tap ⓘ there for line grammar.</p>',

  ENABLE_EXPLORED_TAGS:
      '<p>Also toggle from the dock (<strong>Explored tags</strong>). When enabled, explored-tag rules hide matching posts while you page.</p>' +
      '<p>Hover a sidebar tag and click the compass to add a tag to your explore list. Click again to remove. ' +
      'A strike on the compass means it is active; the row gets a soft red tint.</p>' +
      '<p>Use this secondary list while working through huge result sets; when done exploring a tag, hide it on the main blacklist.</p>',

  TAG_BLACKLIST:
      '<p>Hide posts with these tags. One tag name per line (underscores, not spaces).</p>' +
      '<p>Combine with <code>&amp;</code> (and) / <code>|</code> (or); add <code>// note</code> for comments (stored separately).</p>' +
      '<p>Sidebar hide buttons add sole tags here when the blacklist is enabled. ' +
      'Open <strong>Active Hide Rules</strong> on the dock to see what removed posts and remove matching lines.</p>',

  EXPLORED_TAGS:
      '<p>Hide posts with these explored tags while paging through large result sets. One tag name per line (underscores, not spaces).</p>' +
      '<p>Sidebar compass buttons add here when the tracker is enabled. Same line grammar as the blacklist: ' +
      '<code>&amp;</code> (and), <code>|</code> (or), optional <code>//</code> comments.</p>',

  HIDE_OLDER_POSTS:
      '<p>Turn on from the dock. Hides search results whose post id is lower than <strong>Last ID</strong>.</p>' +
      '<p>Hover this control and choose <strong>Set Latest ID</strong> on a search page (highest id on the page) ' +
      'or <strong>Set Post ID</strong> on a post. Or type Last ID manually in Filters.</p>',

  LAST_ID:
      '<p>Posts with an id below this value are hidden when <strong>Hide Older Posts</strong> is on.</p>' +
      '<p>Set manually or via dock capture buttons. Useful for continuing from where you left off in large result sets.</p>',

  AUTO_NEXT_PAGE:
      '<p>Dock toggle. On search pages, when compliance hides every post on the current page, the script loads the next page.</p>' +
      '<p>Useful with strict blacklists or hide-downloaded filters while paging.</p>',

  SET_LATEST_ID:
      '<p>Sets <strong>Last ID</strong> to the highest post id visible on the current search page.</p>' +
      '<p>Used with <strong>Hide Older Posts</strong> to skip earlier results. Available from the Hide Older Posts dock slide-out on search pages.</p>',

  SET_POST_ID:
      '<p>Sets <strong>Last ID</strong> to the current media post id.</p>' +
      '<p>Used with <strong>Hide Older Posts</strong> to hide lower-id search results. Available from the Hide Older Posts dock slide-out on media pages.</p>',

  SKIP_DUPLICATE:
      '<p>Dock control next to Start/Pause (on by default). Remembers post ids this script already queued — not what is on your disk.</p>' +
      '<p>When on, the same post is not saved again. Turn off to save again after you deleted files locally. ' +
      'Memory has no fixed size cap and is included in backup/restore (merged on import).</p>' +
      '<p>Repeating a save overwrites the file on disk; the script does not add <code>(1)</code> copies.</p>',

  HIDE_DOWNLOADED:
      '<p>Hover slide-out on the Skip Duplicate dock control (always available, even when Skip is off).</p>' +
      '<p>Uses the same download ledger memory to hide tiles you already saved. New downloads are recorded when ' +
      '<strong>Skip Duplicate Downloads</strong> or <strong>Hide Downloaded Media</strong> is on.</p>',

  PAGINATION_LIMIT:
      '<p>Caps how many extra result pages the paginator will fetch when merging results on supported sites.</p>',

  PAGINATION_THRESHOLD:
      '<p>Target minimum visible results before the paginator stops fetching additional pages on supported sites.</p>',

  BOOKMARK_SEARCH:
      '<p>Save the current search as a one-click bookmark from the dock star on search pages.</p>' +
      '<p>Manage saved searches in the Bookmarks settings panel when your script provides one.</p>',

  AUTO_HIDE_SETTINGS_PANE:
      '<p>When enabled, the settings panel closes automatically one second after the pointer leaves it.</p>' +
      '<p>Re-entering the settings panel or a review panel (Active Hide Rules, Tag Discovery, human-interaction, etc.) cancels the pending close. ' +
      'Dragging the width resizer also prevents auto-hide until you release the mouse.</p>',
})

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

/** Regex capture/group fragments keyed by download-pattern chip token. */
const DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN = {
  id: '(?<id>\\d+)',
  md5: '[0-9a-f]{32}',
  width: '\\d+',
  height: '\\d+',
  score: '\\d+',
  rating: '[a-z]+',
  author: '.+?',
  copyright: '.+?',
  character: '.+?',
  meta: '.+?',
  general: '.+?',
}

/** Canonical Gelbooru-family download pattern sections (shared with ledger folder import). */
const DEFAULT_DOWNLOAD_PATTERN_SECTIONS = [
  {
    label: 'Post',
    chips: [
      {token: 'id', label: 'ID'},
      {token: 'md5', label: 'File hash'},
      {token: 'width', label: 'Width'},
      {token: 'height', label: 'Height'},
      {token: 'score', label: 'Score'},
      {token: 'rating', label: 'Rating'},
    ],
  },
  {
    label: 'Tags',
    chips: [
      {token: 'author', label: 'Artists'},
      {token: 'copyright', label: 'Series'},
      {token: 'character', label: 'Characters'},
      {token: 'meta', label: 'Meta'},
      {token: 'general', label: 'General'},
    ],
  },
]

/**
 * Build label → regex map from download pattern sections (Toolbox ledger import).
 * @param {{label: string, chips: {token?: string, insert?: string, label: string}[]}[]} patternSections
 * @param {Record<string, string>} [overrides]
 * @return {Record<string, string>}
 */
function buildLedgerImportLabelToRegex(patternSections, overrides = {})
{
  let map = {}
  for (let section of patternSections ?? []) {
    for (let chip of section.chips ?? []) {
      let token = chip.token ?? chip.insert
      if (token && chip.label && DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN[token]) {
        map[chip.label] = DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN[token]
      }
    }
  }
  return {...map, ...overrides}
}

/** Default label → regex map for {@link DEFAULT_DOWNLOAD_PATTERN_SECTIONS}. */
const DEFAULT_LEDGER_IMPORT_LABEL_TO_REGEX = buildLedgerImportLabelToRegex(DEFAULT_DOWNLOAD_PATTERN_SECTIONS)

/**
 * @param {string} fieldKey
 * @return {{templateId: string, templateConfig: object, config: object}|null}
 */
function rulesetFieldSeedFromSpec(fieldKey)
{
  let spec = typeof getRulesetFieldSpec === 'function' ? getRulesetFieldSpec(fieldKey) : null
  if (!spec) {
    return null
  }
  return {
    templateId: spec.templateId,
    templateConfig: spec.templateConfig ?? {},
    config: {...DEFAULT_RULESET_USER_CONFIG},
  }
}

/** Post ids buffered in RAM per IndexedDB flush during folder import. */
const DOWNLOAD_LEDGER_IMPORT_BATCH_SIZE = 4096

/** Progress bar refresh step as a percent of total files (1 = every 1%). */
const DOWNLOAD_LEDGER_IMPORT_PROGRESS_STEP_PERCENT = 1

/** Event-loop yield step as a percent of total files. */
const DOWNLOAD_LEDGER_IMPORT_YIELD_STEP_PERCENT = 2

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

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

  /**
   * @typedef {{patternSections: {label: string, chips: {token?: string, insert?: string, label: string, title: string}[]}[],
   *            patternSeparators: {insert: string, label: string, title: string}[],
   *            filenamePatternConfigKey?: string,
   *            idTokenLabel?: string,
   *            labelToRegex?: Record<string, string>,
   *            patternBuilderEventNamespace?: string}} DownloadLedgerImportConfiguration
   */

  /**
   * @typedef {{configKey: string, otherTagSectionsSelector?: HTMLElement, styleClass: string, rows?: int, help: 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

  /**
   * Set when {@link createDownloadsTabPanel} includes a shared pattern builder; drives post-embed patching.
   * @type {{folderConfigKey: string, subfolderPatternConfigKey: string, filenamePatternConfigKey: string,
   *     patternBuilderEventNamespace: string}|null}
   * @private
   */
  _downloadsTabPanelConfig = null

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

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

  /**
   * When true, {@link hideDockMigrationStatus} is a no-op and post-build re-shows migrating UI (style testing).
   * @type {boolean}
   * @protected
   */
  _forceDockMigrationStatus = false

  /**
   * @type {HTMLElement|null}
   * @protected
   */
  _migrationPanel = null

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

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

  /**
   * @type {{fieldKeys: Set<string>, normalizeRuleLine: function(string): string, getRuleColor?: function(string, string): (string|null)}|null}
   * @private
   */
  _removableTagComplianceFilters = 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

  /**
   * Trailing debounce for window resize / stack ResizeObserver panel sync.
   * @type {number|null}
   * @private
   */
  _dockPanelResizeSyncTimer = null

  /**
   * Prevents nested stack sync while programmatic layout writes run.
   * @type {boolean}
   * @private
   */
  _dockPanelSyncInProgress = false

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

  /**
   * @type {AbortController|null}
   * @private
   */
  _hideDownloadedResyncAbort = null

  /**
   * @type {AbortController|null}
   * @private
   */
  _paginatorKeyboardNavAbort = 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

  /**
   * Toolbox Download Ledger summary line (`createFormSectionIntro`).
   * @type {HTMLElement|null}
   * @private
   */
  _downloadLedgerCountEl = null

  /**
   * Ignores stale async ledger count responses.
   * @type {number}
   * @private
   */
  _downloadLedgerCountRefreshGeneration = 0

  /**
   * Debounce timer for ledger-driven Toolbox count refreshes.
   * @type {ReturnType<typeof setTimeout>|null}
   * @private
   */
  _downloadLedgerCountRefreshTimer = null

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

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

  /**
   * @type {DownloadLedgerImportConfiguration|null}
   * @protected
   */
  _downloadLedgerImportConfig = 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()

  /**
   * In-flight ensureNames for bookmark-row attribute status (keyed by attemptedKey).
   * Not a permanent attempted set — cache eviction / clearCache must be allowed to re-fetch.
   * @type {Map<string, Set<string>>}
   * @private
   */
  _bookmarkRowAttributePrefetchInFlight = 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()
      },
    })

    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)
    })
    /** @type {Function[]} */
    this._settingsDetailPaneListeners = []
    BrazenViewLayer.onSettingsDetailPaneChange((isOpen, ctx) => {
      for (let listener of this._settingsDetailPaneListeners) {
        listener(isOpen, ctx)
      }
    })
    this._configurationManager.addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
        setTitle('Disable All Filters').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.DISABLE_ALL_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 enableFlag = this._configurationManager.addFlagField(enableConfigKey).
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.SKIP_DUPLICATE)
    if (enableConfigKey === OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER) {
      enableFlag.setTitle('Skip Duplicate Downloads')
    }
    if (ledgerConfig.enableDefault !== false) {
      this._configurationManager.registerFieldSeed(enableConfigKey, 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} help
   * @param rows
   * @protected
   */
  _addItemBlacklistFilter(help, rows = 5)
  {
    this._configurationManager.addFlagField(OPTION_ENABLE_TEXT_BLACKLIST).
        setTitle('Enable Text Blacklist').
        setHelp('Applies the blacklist.')
    this._configurationManager.addRulesetField(FILTER_TEXT_BLACKLIST).
        setTitle('Blacklist').
        setRows(rows).
        setHelp(help).
        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} help
   * @param {string} separator
   * @protected
   */
  _addItemDurationRangeFilter(durationNodeSelector, help = null, separator = ':')
  {
    this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 0, 100000).
        setTitle('Duration').
        setHelp(help ?? '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} help
   * @param {string|null} unratedHelp
   * @protected
   */
  _addItemPercentageRatingRangeFilter(ratingNodeSelector, help = null, unratedHelp = null)
  {
    this._configurationManager.addRangeField(FILTER_PERCENTAGE_RATING_RANGE, 0, 100000).
        setTitle('Rating').
        setHelp(help ?? 'Filter items by percentage rating.')
    this._configurationManager.addFlagField(FILTER_UNRATED).
        setTitle('Unrated').
        setHelp(unratedHelp ?? '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} key
   * @param {string|null} optionKey
   * @param {string|null} dockTemplateName Named CM dock template (default `tagBlacklist` for primary blacklist).
   * @protected
   */
  _addItemTagBlacklistFilter(attribute, useSelectors, rows = 5, key = null, optionKey = null, dockTemplateName = null)
  {
    if (key === null) {
      key = FILTER_TAG_BLACKLIST
    }
    if (optionKey === null) {
      optionKey = OPTION_ENABLE_TAG_BLACKLIST
    }

    let enableHelp = optionKey === OPTION_ENABLE_EXPLORED_TAGS_TRACKER ?
        FRAMEWORK_FIELD_DETAILED_HELP.ENABLE_EXPLORED_TAGS :
        FRAMEWORK_FIELD_DETAILED_HELP.ENABLE_TAG_BLACKLIST
    let rulesetHelp = key === FILTER_TAG_BLACKLIST ?
        FRAMEWORK_FIELD_DETAILED_HELP.TAG_BLACKLIST :
        FRAMEWORK_FIELD_DETAILED_HELP.EXPLORED_TAGS

    let enableField = this._configurationManager.addFlagField(optionKey).setHelp(enableHelp)
    if (optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
      enableField.setTitle('Enable Tag Blacklist')
    } else if (optionKey === OPTION_ENABLE_EXPLORED_TAGS_TRACKER) {
      enableField.setTitle('Enable Explored Tags Tracker')
    }

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

    this._configurationManager.addRulesetField(key).
        setTemplate(key === FILTER_TAG_BLACKLIST ? 'tag-blacklist' : 'explored-tags').
        setTemplateConfig({
          compileGroup: key === FILTER_TAG_BLACKLIST ? 'blacklist' : 'explored',
          useSelectors: false,
        }).
        setGroupingAvailable(true).
        setTitle(key === FILTER_TAG_BLACKLIST ? 'Tag Blacklist' : 'Explored Tags Tracker').
        setHelp(rulesetHelp)

    let seed = rulesetFieldSeedFromSpec(key)
    if (seed) {
      this._configurationManager.registerFieldSeed(key, seed)
    }

    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
    }
    return this._configurationManager.hasTagComplianceRules(fieldKey)
  }

  /**
   * @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
    }
    let result = this._configurationManager.evaluateTagCompliance(itemTags, fieldKey)
    return result.complies ? true : result
  }

  /**
   * 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} [optionKey]
   * @param {{resyncOnFocusAndVisibility?: boolean, resyncPages?: string|string[]}} [options]
   * @protected
   */
  _addItemHideDownloadedMediaFilter(getItemDownloadId, optionKey = OPTION_HIDE_DOWNLOADED_MEDIA, options = {})
  {
    this._hideDownloadedGetItemId = getItemDownloadId
    let field = this._configurationManager.addFlagField(optionKey)
    if (optionKey === OPTION_HIDE_DOWNLOADED_MEDIA) {
      field.setTitle('Hide Downloaded Media').
          setHelp(FRAMEWORK_FIELD_DETAILED_HELP.HIDE_DOWNLOADED)
    }
    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
        },
    )

    if (options.resyncOnFocusAndVisibility) {
      let setup = () => this._wireHideDownloadedFocusVisibilityResync()
      if (options.resyncPages) {
        this._forPage(options.resyncPages, setup)
      } else {
        setup()
      }
    }
  }

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

    this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION).
        setTitle('Text Sanitization Rules').
        setRows(2).
        setHelp(help).
        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} help
   * @protected
   */
  _addItemTextSearchFilter(help = null)
  {
    this._configurationManager.addTextField(FILTER_TEXT_SEARCH).
        setTitle('Search').
        setHelp(help ?? '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} help
   * @protected
   */
  _addItemWhitelistFilter(help)
  {
    this._configurationManager.addRulesetField(FILTER_TEXT_WHITELIST).
        setTitle('Whitelist').
        setRows(5).
        setHelp(help).
        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').
        setHelp('Hide videos from subscribed channels.')
    this._configurationManager.addTextField(STORE_SUBSCRIPTIONS).
        setTitle('Account Subscriptions').
        setHelp('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)
        let filterValue = this._configurationManager.getOptimized(complianceFilter.configKey)
        if (complianceFilter.validate(filterValue)) {
          let complyResult = complianceFilter.comply(item, filterValue)
          let {complies, rule} = this._unwrapComplianceResult(complyResult)
          itemComplies = complies
          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 offset = 0; offset < items.length; offset += COMPLIANCE_TILE_BATCH_SIZE) {
      let batch = items.slice(offset, offset + COMPLIANCE_TILE_BATCH_SIZE)
      for (let item of batch) {
        item.style.opacity = '0.75'
      }
      for (let item of batch) {
        this._complyItem(item)
        if (!this._get(item, ITEM_PROCESSED_ONCE)) {
          Utilities.processEventHandlerQueue(this._onFirstHitAfterCompliance, [item])
          this._itemAttributesResolver.set(item, ITEM_PROCESSED_ONCE, true)
        }
      }
      if (offset + COMPLIANCE_TILE_BATCH_SIZE < items.length) {
        await Utilities.sleep(0)
      }
    }

    Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
  }

  /**
   * @return {boolean}
   * @protected
   */
  _needsTagCompliancePriming()
  {
    return this._validateTagComplianceFilter(FILTER_TAG_BLACKLIST, OPTION_ENABLE_TAG_BLACKLIST) ||
        this._validateTagComplianceFilter('explored-tags-tracker', OPTION_ENABLE_EXPLORED_TAGS_TRACKER)
  }

  /**
   * 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 || !this._needsTagCompliancePriming()) {
      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)
  }

  /**
   * Downloads settings tab — folder/patterns, substitutions, and ignore list when registered by Download Manager.
   * Does not include **Start in Selection Mode** (`download-selection-mode-default`); mount that via
   * {@link createBehavioursTabPanel}.
   * Pass `Downloads` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
   *
   * @param {{tabName?: string, isFirst?: boolean, patternSections?: object[], patternSeparators?: object[],
   *     patternBuilderEventNamespace?: string, folderConfigKey?: string, subfolderPatternConfigKey?: string,
   *     filenamePatternConfigKey?: string, stripCharacterSeriesConfigKey?: string, substitutionsFieldKey?: string,
   *     tagIgnoreFieldKey?: string}} [options]
   * @return {HTMLElement}
   */
  createDownloadsTabPanel(options = {})
  {
    let tabName = options.tabName ?? 'Downloads'
    let paths = this._downloadManager?._config?.downloadPaths
    let folderKey = options.folderConfigKey ?? paths?.folderConfigKey ?? 'download-folder'
    let subfolderKey = options.subfolderPatternConfigKey ?? paths?.subfolderPatternConfigKey ?? 'subfolder-pattern'
    let filenameKey = options.filenamePatternConfigKey ?? paths?.filenamePatternConfigKey ?? 'filename-pattern'
    let stripKey = options.stripCharacterSeriesConfigKey ?? paths?.stripCharacterSeriesConfigKey ??
        'strip-series-from-character-tags'
    let subsKey = options.substitutionsFieldKey ?? paths?.substitutionsFieldKey ?? 'filename-tag-substitutions'
    let ignoreKey = options.tagIgnoreFieldKey ?? paths?.tagIgnoreFieldKey ?? 'filename-tag-ignore-list'
    let elements = []
    let addPatternGroup = (key) => {
      if (!this._configurationManager.hasField(key)) {
        return
      }
      let element = this._configurationManager.createElement(key)
      element.classList.add('bv-pattern-group')
      elements.push(element)
    }
    addPatternGroup(folderKey)
    addPatternGroup(subfolderKey)
    addPatternGroup(filenameKey)
    if (options.patternSections?.length && options.patternSeparators?.length) {
      elements.push(this._uiGen.createSharedPatternTokenBuilder(options.patternSections, options.patternSeparators))
      this._downloadsTabPanelConfig = {
        folderConfigKey: folderKey,
        subfolderPatternConfigKey: subfolderKey,
        filenamePatternConfigKey: filenameKey,
        patternBuilderEventNamespace: options.patternBuilderEventNamespace ?? 'bvpattern',
      }
      if (!this._downloadsTabPatchQueued) {
        this._downloadsTabPatchQueued = true
        this._onAfterUIBuild.push(() => this._patchDownloadsTabPatternBuilder())
      }
    }
    if (elements.length) {
      elements.push(this._uiGen.createSeparator())
    }
    if (this._configurationManager.hasField(stripKey)) {
      elements.push(this._configurationManager.createElement(stripKey))
      elements.push(this._uiGen.createSeparator())
    }
    if (this._configurationManager.hasField(subsKey)) {
      elements.push(this._configurationManager.createElement(subsKey))
    }
    if (this._configurationManager.hasField(ignoreKey)) {
      elements.push(this._configurationManager.createElement(ignoreKey))
    }
    return Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
  }

  /**
   * Wire shared pattern-token builder focus/click handlers after the settings panel is embedded.
   * Queued from {@link createDownloadsTabPanel} when pattern sections are supplied.
   *
   * @protected
   */
  _patchDownloadsTabPatternBuilder()
  {
    let config = this._downloadsTabPanelConfig
    if (!config) {
      return
    }
    let filenameEl = this._configurationManager.getField(config.filenamePatternConfigKey)?.element
    let subfolderEl = this._configurationManager.getField(config.subfolderPatternConfigKey)?.element
    let builder = document.querySelector('#bv-ui .bv-pattern-builder-shared')
    this._uiGen.patchPatternTokenBuilder(
        filenameEl,
        subfolderEl,
        builder,
        config.patternBuilderEventNamespace,
    )
    this._uiGen.patchStackedPatternFieldGroups(this._configurationManager, [
      config.folderConfigKey,
      config.filenamePatternConfigKey,
      config.subfolderPatternConfigKey,
    ])
  }

  /**
   * Behaviours settings tab — Framework auto-hide flag when registered, then Download Manager flags
   * (`download-selection-mode-default`, `review-ignored-filename-pins`, `skip-empty-filename-pins`,
   * `defer-tag-discovery-unattended`), in alphabetical title order.
   * 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 keys = [
      OPTION_AUTO_HIDE_SETTINGS_PANE,
      'defer-tag-discovery-unattended',
      'review-ignored-filename-pins',
      'skip-empty-filename-pins',
      'download-selection-mode-default',
    ]
    let elements = []
    for (let key of keys) {
      if (this._configurationManager.hasField(key)) {
        elements.push(this._configurationManager.createElement(key))
      }
    }
    return Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
  }

  /**
   * Toolbox tab — backup/restore, Clear Database, and (when configured) Download Ledger controls.
   * Pass `Toolbox` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
   *
   * @param {{tabName?: string, isFirst?: boolean}} [options]
   * @return {HTMLElement}
   */
  createToolboxTabPanel(options = {})
  {
    let tabName = options.tabName ?? 'Toolbox'
    let elements = [this._createToolboxBackupRestoreSection()]
    if (this._getDownloadDuplicateLedgerField()) {
      elements.push(this._uiGen.createSeparator())
      elements.push(this._createDownloadLedgerToolboxSection())
    }
    elements.push(this._uiGen.createSeparator())
    elements.push(this._createToolboxDatabaseSection())
    return Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
  }

  /**
   * Register pattern-token metadata for {@link #_openDownloadLedgerImportPanel} (required for Import Folder).
   *
   * @param {DownloadLedgerImportConfiguration} config
   * @return {BrazenFramework}
   */
  configureDownloadLedgerImport(config)
  {
    this._downloadLedgerImportConfig = config
    return this
  }

  /**
   * @protected
   * @return {HTMLElement}
   */
  _createToolboxBackupRestoreSection()
  {
    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}
   */
  _createToolboxDatabaseSection()
  {
    let resetButton = BrazenViewLayer.createProgressButton({
      label: 'Reset Tag Discovery',
      title: 'Clear the discovery gate for every tag (isDiscovered). Tag types and rulesets are kept; tag discovery will treat previously confirmed tags as new again.',
      onClick: (api) => this._onResetAllTagsDiscovered(api),
    })
    let section = this._uiGen.createFormSection('Database')
    section.append(this._uiGen.createFormActions([
      resetButton,
      this._uiGen.createFormButton(
          'Clear Database',
          'Delete this script’s entire IndexedDB store (settings, bookmarks, download memory, tags, and queues). The page reloads so the database can be rebuilt from scratch.',
          () => this._onClearScriptDatabase(),
      ),
    ], 'bv-flex-column'))
    return section
  }

  /**
   * @protected
   * @return {HTMLElement}
   */
  _createDownloadLedgerToolboxSection()
  {
    let buttons = []
    if (this._downloadLedgerImportConfig) {
      buttons.push(this._uiGen.createFormButton(
          'Import Folder',
          'Choose a download folder and import post ids into the ledger using your filename pattern.',
          () => this._openDownloadLedgerImportPanel(),
      ))
    }
    buttons.push(this._uiGen.createFormButton(
        'Clear Ledger',
        'Delete all remembered download ids from IndexedDB. Hide Downloaded Media and Skip Duplicate Downloads will treat prior saves as unseen until they are downloaded again.',
        () => this._onClearDownloadLedger(),
    ))
    let section = this._uiGen.createFormSection('Download Ledger')
    let summary = this._uiGen.createFormSectionIntro(this._formatDownloadLedgerCountSentence(null))
    this._downloadLedgerCountEl = summary
    section.append(summary)
    section.append(this._uiGen.createFormActions(buttons))
    void this._refreshDownloadLedgerToolboxCount(summary)
    this._onAfterUIBuild.push(() => this._wireDownloadLedgerToolboxCountRefresh())
    return section
  }

  /**
   * @param {number|null|undefined} count `null`/`undefined` = loading; `-1` = error
   * @return {string}
   * @protected
   */
  _formatDownloadLedgerCountSentence(count)
  {
    if (count === null || count === undefined) {
      return 'The download ledger contains … post ids.'
    }
    if (count === -1) {
      return 'Could not read the download ledger count.'
    }
    if (count === 0) {
      return 'The download ledger contains no post ids.'
    }
    if (count === 1) {
      return 'The download ledger contains 1 post id.'
    }
    return `The download ledger contains ${count.toLocaleString()} post ids.`
  }

  /**
   * @param {HTMLElement} [summaryEl]
   * @return {Promise<void>}
   * @protected
   */
  _refreshDownloadLedgerToolboxCount(summaryEl = this._downloadLedgerCountEl)
  {
    let el = summaryEl ?? this._downloadLedgerCountEl
    if (!el?.isConnected) {
      return Promise.resolve()
    }
    let generation = ++this._downloadLedgerCountRefreshGeneration
    el.textContent = this._formatDownloadLedgerCountSentence(null)
    return this._configurationManager.countDownloadLedger().
        then((count) => {
          if (generation !== this._downloadLedgerCountRefreshGeneration || !el.isConnected) {
            return
          }
          el.textContent = this._formatDownloadLedgerCountSentence(count)
        }).
        catch((error) => {
          console.error(error)
          if (generation !== this._downloadLedgerCountRefreshGeneration || !el.isConnected) {
            return
          }
          el.textContent = this._formatDownloadLedgerCountSentence(-1)
        })
  }

  /**
   * @protected
   */
  _scheduleDownloadLedgerToolboxCountRefresh()
  {
    if (!this._downloadLedgerCountEl?.isConnected) {
      return
    }
    if (this._downloadLedgerCountRefreshTimer) {
      clearTimeout(this._downloadLedgerCountRefreshTimer)
    }
    this._downloadLedgerCountRefreshTimer = setTimeout(() => {
      this._downloadLedgerCountRefreshTimer = null
      void this._refreshDownloadLedgerToolboxCount()
    }, 1000)
  }

  /**
   * @protected
   */
  _wireDownloadLedgerToolboxCountRefresh()
  {
    for (let btn of document.querySelectorAll('.bv-tabs-nav .bv-tab-button')) {
      if (Utilities.toKebabCase(btn.textContent) !== 'toolbox') {
        continue
      }
      btn.addEventListener('click', () => {
        void this._refreshDownloadLedgerToolboxCount()
      })
      break
    }
  }

  /**
   * @return {string[]}
   * @protected
   */
  _getDownloadLedgerImportPatternLabels()
  {
    let sections = this._downloadLedgerImportConfig?.patternSections ?? []
    return sections.
        flatMap((section) => section.chips.map((chip) => chip.label)).
        sort((a, b) => b.length - a.length)
  }

  /**
   * @return {Record<string, string>}
   * @protected
   */
  _getDownloadLedgerImportLabelToRegex()
  {
    let sections = this._downloadLedgerImportConfig?.patternSections ?? DEFAULT_DOWNLOAD_PATTERN_SECTIONS
    return buildLedgerImportLabelToRegex(sections, this._downloadLedgerImportConfig?.labelToRegex)
  }

  /**
   * @return {string}
   * @protected
   */
  _getDownloadLedgerImportIdTokenLabel()
  {
    return this._downloadLedgerImportConfig?.idTokenLabel ?? 'ID'
  }

  /**
   * @param {string} pattern
   * @return {boolean}
   * @protected
   */
  _downloadLedgerImportPatternHasIdToken(pattern)
  {
    if (!pattern || typeof pattern !== 'string') {
      return false
    }
    let idLabel = this._getDownloadLedgerImportIdTokenLabel()
    let labels = this._getDownloadLedgerImportPatternLabels()
    let i = 0
    while (i < pattern.length) {
      let matched = false
      for (let label of labels) {
        if (pattern.startsWith(label, i)) {
          if (label === idLabel) {
            return true
          }
          i += label.length
          matched = true
          break
        }
      }
      if (!matched) {
        let nextToken = pattern.length
        for (let label of labels) {
          let idx = pattern.indexOf(label, i + 1)
          if (idx !== -1 && idx < nextToken) {
            nextToken = idx
          }
        }
        i = nextToken
      }
    }
    return false
  }

  /**
   * @param {string} pattern
   * @return {RegExp|null}
   * @protected
   */
  _buildDownloadLedgerFilenameRegex(pattern)
  {
    if (!this._downloadLedgerImportPatternHasIdToken(pattern)) {
      return null
    }
    let labels = this._getDownloadLedgerImportPatternLabels()
    let labelToRegex = this._getDownloadLedgerImportLabelToRegex()
    let escapeRegex = (str) => str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
    let parts = []
    let i = 0
    while (i < pattern.length) {
      let matched = false
      for (let label of labels) {
        if (pattern.startsWith(label, i)) {
          parts.push(labelToRegex[label] ?? escapeRegex(label))
          i += label.length
          matched = true
          break
        }
      }
      if (!matched) {
        let nextToken = pattern.length
        for (let label of labels) {
          let idx = pattern.indexOf(label, i + 1)
          if (idx !== -1 && idx < nextToken) {
            nextToken = idx
          }
        }
        parts.push(escapeRegex(pattern.slice(i, nextToken)))
        i = nextToken
      }
    }
    try {
      return new RegExp(`^${parts.join('')}$`, 'i')
    } catch (error) {
      console.error(error)
      return null
    }
  }

  /**
   * @param {File} file
   * @param {RegExp} regex
   * @param {function(string): boolean} isValidId
   * @return {string|null}
   * @protected
   */
  _extractDownloadLedgerImportIdFromFile(file, regex, isValidId)
  {
    let name = file.name || (file.webkitRelativePath?.split('/').pop() ?? '')
    if (!name) {
      return null
    }
    let dot = name.lastIndexOf('.')
    let basename = dot > 0 ? name.slice(0, dot) : name
    let match = basename.match(regex)
    if (!match?.groups?.id) {
      return null
    }
    let id = String(match.groups.id).trim()
    return isValidId(id) ? id : null
  }

  /**
   * @return {Promise<void>}
   * @protected
   */
  _yieldDownloadLedgerImportTurn()
  {
    return new Promise((resolve) => {
      setTimeout(resolve, 0)
    })
  }

  /**
   * @return {{lastReportBucket: number, lastYieldBucket: number}}
   * @protected
   */
  _createDownloadLedgerImportProgressState()
  {
    return {lastReportBucket: -1, lastYieldBucket: -1}
  }

  /**
   * @param {number} index
   * @param {number} total
   * @param {{scanned: number, matched: number, skipped: number}} stats
   * @param {function({scanned: number, matched: number, skipped: number, total: number}): void|null|undefined} onProgress
   * @param {{lastReportBucket: number, lastYieldBucket: number}} progressState
   * @return {boolean} whether the caller should yield to the event loop
   * @protected
   */
  _maybeDownloadLedgerImportProgress(index, total, stats, onProgress, progressState)
  {
    if (!onProgress || total <= 0) {
      return false
    }
    let isLast = index === total - 1
    let percent = Math.floor(((index + 1) * 100) / total)
    let reportBucket = Math.floor(percent / DOWNLOAD_LEDGER_IMPORT_PROGRESS_STEP_PERCENT)
    if (!isLast && reportBucket <= progressState.lastReportBucket) {
      return false
    }
    progressState.lastReportBucket = reportBucket
    onProgress({...stats, total})
    let yieldBucket = Math.floor(percent / DOWNLOAD_LEDGER_IMPORT_YIELD_STEP_PERCENT)
    if (isLast || yieldBucket > progressState.lastYieldBucket) {
      progressState.lastYieldBucket = yieldBucket
      return true
    }
    return false
  }

  /**
   * Count matching files without accumulating post ids in RAM.
   *
   * @param {FileList|File[]} fileList
   * @param {string} pattern
   * @param {function({scanned: number, matched: number, skipped: number, total: number}): void} [onProgress]
   * @return {Promise<{scanned: number, matched: number, skipped: number}>}
   * @protected
   */
  async _countDownloadLedgerImportFiles(fileList, pattern, onProgress = null)
  {
    let regex = this._buildDownloadLedgerFilenameRegex(pattern)
    if (!regex) {
      return {scanned: 0, matched: 0, skipped: 0}
    }
    let isValidId = this._getDownloadLedgerImportIsValidId()
    let scanned = 0
    let matched = 0
    let skipped = 0
    let total = fileList.length
    let progressState = this._createDownloadLedgerImportProgressState()
    for (let i = 0; i < total; i++) {
      scanned++
      if (this._extractDownloadLedgerImportIdFromFile(fileList[i], regex, isValidId)) {
        matched++
      } else {
        skipped++
      }
      if (this._maybeDownloadLedgerImportProgress(i, total, {scanned, matched, skipped}, onProgress, progressState)) {
        await this._yieldDownloadLedgerImportTurn()
      }
    }
    return {scanned, matched, skipped}
  }

  /**
   * Stream folder filenames into the ledger in bounded batches (no full id list in RAM).
   *
   * @param {FileList|File[]} fileList
   * @param {string} pattern
   * @param {boolean} replace
   * @param {function({scanned: number, matched: number, skipped: number, total: number}): void} [onProgress]
   * @return {Promise<{scanned: number, matched: number, skipped: number}>}
   * @protected
   */
  async _importDownloadLedgerFromFileList(fileList, pattern, replace, onProgress = null)
  {
    let regex = this._buildDownloadLedgerFilenameRegex(pattern)
    if (!regex) {
      return {scanned: 0, matched: 0, skipped: 0}
    }
    let isValidId = this._getDownloadLedgerImportIsValidId()
    let cm = this._configurationManager
    if (!(await cm.beginDownloadLedgerFolderImport(replace))) {
      throw new Error('download-ledger-import-unavailable')
    }
    let scanned = 0
    let matched = 0
    let skipped = 0
    let total = fileList.length
    /** @type {string[]} */
    let batch = []
    let batchSeen = new Set()
    let progressState = this._createDownloadLedgerImportProgressState()
    let flushBatch = async () => {
      if (!batch.length) {
        return
      }
      await cm.mergeDownloadLedgerImportBatch(batch)
      batch = []
      batchSeen = new Set()
      await this._yieldDownloadLedgerImportTurn()
    }
    try {
      for (let i = 0; i < total; i++) {
        scanned++
        let id = this._extractDownloadLedgerImportIdFromFile(fileList[i], regex, isValidId)
        if (!id) {
          skipped++
        } else {
          matched++
          if (!batchSeen.has(id)) {
            batchSeen.add(id)
            batch.push(id)
            if (batch.length >= DOWNLOAD_LEDGER_IMPORT_BATCH_SIZE) {
              await flushBatch()
            }
          }
        }
        if (this._maybeDownloadLedgerImportProgress(i, total, {scanned, matched, skipped}, onProgress, progressState)) {
          await this._yieldDownloadLedgerImportTurn()
        }
      }
      await flushBatch()
      await cm.finalizeDownloadLedgerFolderImport()
    } catch (error) {
      try {
        await cm.finalizeDownloadLedgerFolderImport()
      } catch (finalizeError) {
        console.error(finalizeError)
      }
      throw error
    }
    return {scanned, matched, skipped}
  }

  /**
   * @return {function(string): boolean}
   * @protected
   */
  _getDownloadLedgerImportIsValidId()
  {
    let field = this._getDownloadDuplicateLedgerField()
    if (typeof field?.ledgerIsValidId === 'function') {
      return field.ledgerIsValidId
    }
    if (typeof field?._ledgerIsValidId === 'function') {
      return field._ledgerIsValidId()
    }
    return (value) => typeof value === 'string' && value.trim().length > 0
  }

  /**
   * @param {HTMLSelectElement|null|undefined} modeSelect
   * @return {'append'|'replace'}
   * @protected
   */
  _getDownloadLedgerImportMode(modeSelect)
  {
    return modeSelect?.value === 'replace' ? 'replace' : 'append'
  }

  /**
   * Site-specific pattern pane for ledger folder import (token builder + validation).
   *
   * @param {{patternInput: HTMLInputElement, syncImportButton: function(): void}} host
   * @return {HTMLElement}
   * @protected
   */
  _createDownloadLedgerImportPatternPane(host)
  {
    let config = this._downloadLedgerImportConfig
    if (!config) {
      return Utilities.makeEl('div')
    }
    let patternInput = host.patternInput
    let patternValidation = Utilities.makeEl('p', {
      class: 'bv-ledger-import-validation',
      text: '',
    })
    let hiddenSubfolder = this._uiGen.createFormGroupInput('text')
    hiddenSubfolder.hidden = true
    let builder = this._uiGen.createSharedPatternTokenBuilder(
        config.patternSections,
        config.patternSeparators,
    )
    this._uiGen.patchPatternTokenBuilder(
        patternInput,
        hiddenSubfolder,
        builder,
        config.patternBuilderEventNamespace ?? 'bvledgerimport',
    )
    let syncValidation = () => {
      let hasId = this._downloadLedgerImportPatternHasIdToken(patternInput.value)
      patternValidation.textContent = hasId ?
          '' :
          `Filename pattern must include the ${this._getDownloadLedgerImportIdTokenLabel()} token so post ids can be extracted.`
      patternValidation.classList.toggle('bv-ledger-import-validation-error', !hasId)
      host.syncImportButton()
    }
    patternInput.addEventListener('input', syncValidation)
    syncValidation()
    let patternGroup = this._uiGen.createDetailFormFieldGroup(
        'Filename pattern',
        [patternInput, builder, patternValidation],
        'Must match your saved filenames. Include the ID token.',
    )
    patternGroup.classList.add('bv-pattern-group')
    return patternGroup
  }

  /**
   * @param {{title: string, lines: string[], note?: string}} summary
   * @param {HTMLElement} summarySection
   * @param {HTMLElement} summaryTitle
   * @param {HTMLElement} summaryBody
   * @protected
   */
  _renderDownloadLedgerImportSummary(summarySection, summaryTitle, summaryBody, summary)
  {
    summaryTitle.textContent = summary.title
    summaryBody.replaceChildren()
    for (let line of summary.lines) {
      summaryBody.append(Utilities.makeEl('p', {
        class: 'bv-ledger-import-summary-line',
        text: line,
      }))
    }
    if (summary.note) {
      summaryBody.append(Utilities.makeEl('p', {
        class: 'bv-ledger-import-summary-note',
        text: summary.note,
      }))
    }
  }

  /**
   * @param {{scanned: number, matched: number, skipped: number}} stats
   * @param {'append'|'replace'} mode
   * @return {string[]}
   * @protected
   */
  _formatDownloadLedgerImportSummaryLines(stats, mode)
  {
    let modeLabel = mode === 'replace' ? 'Replace ledger' : 'Append to ledger'
    return [
      `Scanned: ${stats.scanned.toLocaleString()}`,
      `Matched: ${stats.matched.toLocaleString()}`,
      `Skipped: ${stats.skipped.toLocaleString()}`,
      `Mode: ${modeLabel}`,
    ]
  }

  /**
   * @protected
   */
  _openDownloadLedgerImportPanel()
  {
    if (!this._getDownloadDuplicateLedgerField()) {
      alert('Download ledger is not configured for this script.')
      return
    }
    if (!this._downloadLedgerImportConfig) {
      alert('Download ledger folder import is not configured for this script.')
      return
    }

    let ViewLayer = this._uiGen.constructor
    /** @type {FileList|null} */
    let chosenFiles = null
    let filenameKey = this._downloadLedgerImportConfig.filenamePatternConfigKey ?? 'filename-pattern'

    let folderInput = this._uiGen.createFormGroupInput('file')
    folderInput.classList.add('bv-ledger-import-folder-input')
    folderInput.setAttribute('webkitdirectory', '')
    folderInput.setAttribute('directory', '')
    folderInput.setAttribute('multiple', '')

    let folderStatus = Utilities.makeEl('p', {
      class: 'bv-ledger-import-status',
      text: 'No folder chosen.',
    })

    let modeGroup = this._uiGen.createDetailFormSelectGroup(
        'Add mode',
        [['append', 'Append to ledger'], ['replace', 'Replace ledger']],
        'Append merges new ids with the ledger. Replace clears the ledger first, then writes only the imported ids.',
    )
    let modeSelect = modeGroup.querySelector('select')

    let patternInput = this._uiGen.createFormGroupInput('text')
    patternInput.value = String(this._getConfig(filenameKey) ?? this._getDownloadLedgerImportIdTokenLabel())

    let importButton = this._uiGen.createFormButton(
        'Import',
        'Scan the chosen folder and write matching post ids to the download ledger.',
        () => {},
    )

    let syncImportButton = () => {
      let hasFolder = !!(chosenFiles && chosenFiles.length)
      let hasId = this._downloadLedgerImportPatternHasIdToken(patternInput.value)
      importButton.disabled = !hasFolder || !hasId
    }

    let chooseFolderButton = this._uiGen.createFormButton(
        'Choose folder…',
        'Pick a folder on your computer. Filenames are parsed locally only.',
        () => folderInput.click(),
    )

    folderInput.addEventListener('change', () => {
      chosenFiles = null
      syncImportButton()
      folderStatus.textContent = 'Reading folder…'
      setTimeout(() => {
        chosenFiles = folderInput.files?.length ? folderInput.files : null
        folderStatus.textContent = chosenFiles ?
            `${chosenFiles.length.toLocaleString()} files` :
            'No folder chosen.'
        syncImportButton()
      }, 0)
    })

    syncImportButton()

    let folderControls = Utilities.makeEl('div')
    folderControls.append(chooseFolderButton, folderInput, folderStatus)
    let folderGroup = this._uiGen.createDetailFormFieldGroup(
        'Choose folder',
        folderControls,
        'Select a folder on your computer. Filenames are parsed locally; nothing is uploaded or sent anywhere. ' +
        'Your browser may show an “upload” warning in its own dialog — that is normal for folder access.',
    )

    let patternGroup = this._createDownloadLedgerImportPatternPane({patternInput, syncImportButton})
    let actions = this._uiGen.createDetailFormActions([importButton])

    let configSection = Utilities.makeEl('div', {class: 'bv-ledger-import-config'})
    configSection.append(modeGroup, patternGroup, folderGroup, actions)

    let runSection = Utilities.makeEl('div', {class: 'bv-ledger-import-run'})
    let runProgressPanel = ViewLayer.createSettingsDetailProgressPanel()
    runSection.append(runProgressPanel)

    let summarySection = Utilities.makeEl('div', {class: 'bv-ledger-import-summary'})
    let summaryTitle = Utilities.makeEl('p', {class: 'bv-ledger-import-summary-title', text: ''})
    let summaryBody = Utilities.makeEl('div', {class: 'bv-ledger-import-summary-body'})
    summarySection.append(summaryTitle, summaryBody)

    let content = Utilities.makeEl('div', {class: 'bv-ledger-import-panel'})
    content.append(configSection, runSection, summarySection)

    let resetPanel = () => {
      content.classList.remove('bv-ledger-import-running', 'bv-ledger-import-done')
    }

    let showSummary = (summary) => {
      content.classList.remove('bv-ledger-import-running')
      content.classList.add('bv-ledger-import-done')
      this._renderDownloadLedgerImportSummary(summarySection, summaryTitle, summaryBody, summary)
    }

    importButton.addEventListener('click', () => {
      content.classList.add('bv-ledger-import-running')
      content.classList.remove('bv-ledger-import-done')
      void this._runDownloadLedgerFolderImport({
        chosenFiles: () => chosenFiles,
        getMode: () => this._getDownloadLedgerImportMode(modeSelect),
        getPattern: () => patternInput.value,
        progressPanel: runProgressPanel,
        showSummary,
      })
    })

    ViewLayer.openSettingsDetailPane({
      title: 'Import Ledger from Folder',
      content,
      onClose: () => {
        chosenFiles = null
        resetPanel()
      },
    })
  }

  /**
   * @param {{chosenFiles: function(): FileList|null, getMode: function(): string, getPattern: function(): string, progressPanel?: HTMLElement, showSummary?: function({title: string, lines: string[], note?: string}): void}} options
   * @return {Promise<void>}
   * @protected
   */
  async _runDownloadLedgerFolderImport(options)
  {
    let ViewLayer = this._uiGen.constructor
    let showSummary = (summary) => options.showSummary?.(summary)
    let files = options.chosenFiles?.()
    if (!files?.length) {
      showSummary?.({
        title: 'Import failed',
        lines: ['Choose a folder first.'],
      })
      return
    }
    let pattern = String(options.getPattern?.() ?? '').trim()
    if (!this._downloadLedgerImportPatternHasIdToken(pattern)) {
      showSummary?.({
        title: 'Import failed',
        lines: [`Filename pattern must include the ${this._getDownloadLedgerImportIdTokenLabel()} token.`],
      })
      return
    }
    let progressPanel = options.progressPanel
    let reportScanProgress = ({scanned, matched, skipped, total}) => {
      ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
        label: 'Scanning filenames…',
        current: scanned,
        total,
        matched,
        skipped,
      })
    }
    let reportImportProgress = ({scanned, matched, skipped, total}) => {
      ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
        label: 'Writing ledger…',
        current: scanned,
        total,
        matched,
        skipped,
      })
    }
    ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
      label: 'Scanning filenames…',
      indeterminate: true,
    })
    let mode = options.getMode?.() ?? 'append'
    let replace = mode === 'replace'
    let preview
    try {
      preview = await this._countDownloadLedgerImportFiles(files, pattern, reportScanProgress)
    } catch (error) {
      console.error(error)
      showSummary?.({
        title: 'Import failed',
        lines: ['Could not scan the folder.', 'See the browser console for details.'],
      })
      return
    }
    if (!preview.matched) {
      showSummary?.({
        title: 'No matching files',
        lines: this._formatDownloadLedgerImportSummaryLines(preview, mode),
        note: 'Check that your filename pattern matches saved files.',
      })
      return
    }
    ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
      label: 'Writing ledger…',
      indeterminate: true,
    })
    try {
      let result = await this._importDownloadLedgerFromFileList(files, pattern, replace, reportImportProgress)
      this._validateCompliance()
      showSummary?.({
        title: 'Import complete',
        lines: this._formatDownloadLedgerImportSummaryLines(result, mode),
        note: replace ?
            'The ledger was replaced with imported ids only.' :
            'Duplicate post ids were merged in IndexedDB.',
      })
      void this._refreshDownloadLedgerToolboxCount()
    } catch (error) {
      console.error(error)
      showSummary?.({
        title: 'Import failed',
        lines: ['Could not write to the download ledger.', 'See the browser console for details.'],
      })
    }
  }

  /**
   * @protected
   */
  _onClearDownloadLedger()
  {
    if (!this._getDownloadDuplicateLedgerField()) {
      alert('Download ledger is not configured for this script.')
      return
    }
    if (!confirm(
        'Clear the download ledger?\n\n' +
        'This permanently deletes every remembered download id. Previously downloaded posts can be downloaded again, and Hide Downloaded Media will stop hiding them until they are recorded again.',
    )) {
      return
    }
    void this.clearDownloadDuplicateLedger().
        then(() => {
          alert('Download ledger cleared.')
          void this._refreshDownloadLedgerToolboxCount()
          this._validateCompliance()
        }).
        catch((error) => {
          console.error(error)
          alert('Failed to clear the download ledger. See the browser console for details.')
        })
  }

  /**
   * @protected
   */
  _onClearScriptDatabase()
  {
    if (!confirm(
        'Clear this script’s entire database?\n\n' +
        'Settings, bookmarks, download memory, tags, and download queues will be permanently deleted. ' +
        'The page will reload so the script can rebuild an empty database.',
    )) {
      return
    }
    void this.clearScriptDatabase().
        catch((error) => {
          console.error(error)
          alert('Failed to clear the database. See the browser console for details.')
        })
  }

  /**
   * @param {{setProgress?: Function, setRunning?: Function, reset?: Function}} progressApi
   * @protected
   */
  _onResetAllTagsDiscovered(progressApi)
  {
    if (!confirm(
        'Reset tag discovery for all tags?\n\n' +
        'Every tag that was marked discovered will be treated as undiscovered again. ' +
        'Tag types, bookmarks, and rulesets are not changed.',
    )) {
      return
    }
    progressApi?.setRunning?.(true)
    progressApi?.setProgress?.(0, 1)
    void this.resetAllTagsDiscovered((progress) => {
      progressApi?.setProgress?.(progress?.current ?? 0, progress?.total ?? 1)
    }).
        then((result) => {
          let updated = result?.updated ?? 0
          if (updated === 0) {
            alert('No discovered tags needed resetting.')
          } else if (updated === 1) {
            alert('Reset tag discovery for 1 tag.')
          } else {
            alert(`Reset tag discovery for ${updated.toLocaleString()} tags.`)
          }
          progressApi?.reset?.()
        }).
        catch((error) => {
          console.error(error)
          alert('Failed to reset tag discovery. See the browser console for details.')
          progressApi?.reset?.()
        })
  }

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

  /**
   * @return {HTMLElement}
   * @private
   */
  _mountSettingsUserInterface()
  {
    let section = this._uiGen.createSettingsSection()
    let primary = section._bvSettingsPrimary ?? section.querySelector('#bv-ui-primary') ?? section
    Utilities.appendChildren(primary, this._userInterface)
    BrazenViewLayer.syncSettingsColumnOrder(section, this._getDockOrientation())
    let columnWidth = this._dockConfig.settingsPanelWidth ?? BrazenViewLayer.DEFAULT_SETTINGS_COLUMN_WIDTH
    BrazenViewLayer.applySettingsColumnWidth(section, columnWidth)
    return section
  }

  /**
   * @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 = () => {
      this._cancelMainPanelHideTimer()
    }
    if (panelNode?.addEventListener) {
      panelNode.addEventListener('mouseenter', cancelScheduledHide, {signal})
      panelNode.addEventListener('mouseleave', () => {
        if (!this._isSettingsAutoHideEnabled() || this._uiGen.isSettingsPaneBeingResized()) {
          return
        }
        cancelScheduledHide()
        this._mainPanelHideTimer = setTimeout(() => {
          this._mainPanelHideTimer = null
          if (!this._isSettingsAutoHideEnabled() || this._uiGen.isSettingsPaneBeingResized()) {
            return
          }
          if (!this._anyReviewDockSlidePanelVisible()) {
            this._hideMainPanel(panelNode)
          }
        }, 1000)
      }, {signal})
    }
    this._uiGen.constructor.appendToDockPanelStack(UISection)
    this._uiSection = UISection
    if (this._complianceRules) {
      this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
      this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
      this._uiGen.constructor.appendToDockPanelStack(this._complianceRulesPanel)
    }
    this._buildDock(UISection)
    this._ensureFrameworkUnloadTeardown()
  }

  /**
   * @private
   */
  _teardownMainPanelUiListeners()
  {
    this._cancelMainPanelHideTimer()
    this._mainPanelUiAbort?.abort()
    this._mainPanelUiAbort = null
  }

  /**
   * @private
   */
  _cancelMainPanelHideTimer()
  {
    if (this._mainPanelHideTimer != null) {
      clearTimeout(this._mainPanelHideTimer)
      this._mainPanelHideTimer = null
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  _isSettingsAutoHideEnabled()
  {
    return !!this._getConfig(OPTION_AUTO_HIDE_SETTINGS_PANE)
  }

  /**
   * @return {boolean}
   * @private
   */
  _anyReviewDockSlidePanelVisible()
  {
    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',
    ]) {
      let panel = document.getElementById(id)
      if (panel && this._isDockSlidePanelVisible(panel)) {
        return true
      }
    }
    return false
  }

  /**
   * Cancel pending settings hide when the pointer enters a review slide panel beside settings.
   * @param {HTMLElement} panel
   * @private
   */
  _attachSettingsAutoHideBridge(panel)
  {
    if (!panel || panel.id === 'bv-ui') {
      return
    }
    panel._bvSettingsAutoHideBridgeAbort?.abort()
    let controller = new AbortController()
    panel._bvSettingsAutoHideBridgeAbort = controller
    panel.addEventListener('mouseenter', () => this._cancelMainPanelHideTimer(), {signal: controller.signal})
  }

  /**
   * 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
      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._hideDownloadedResyncAbort?.abort()
      this._hideDownloadedResyncAbort = null
      this._paginatorKeyboardNavAbort?.abort()
      this._paginatorKeyboardNavAbort = null
      this._disconnectItemListChildObservers()
      this._teardownMainPanelUiListeners()
      if (this._afterComplianceRunTimer != null) {
        clearTimeout(this._afterComplianceRunTimer)
        this._afterComplianceRunTimer = null
      }
      if (this._ledgerComplianceRefreshTimer) {
        clearTimeout(this._ledgerComplianceRefreshTimer)
        this._ledgerComplianceRefreshTimer = 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._scheduleDockPanelResizeSync(), {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._dockPanelResizeSyncTimer != null) {
      clearTimeout(this._dockPanelResizeSyncTimer)
      this._dockPanelResizeSyncTimer = 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
  }

  /**
   * Keep the migrating panel visible after init (for style review). When set, hide is ignored.
   * @param {boolean} force
   * @return {BrazenFramework}
   */
  setForceDockMigrationStatus(force)
  {
    this._forceDockMigrationStatus = !!force
    return this
  }

  /**
   * @return {void}
   * @private
   */
  _ensureMigrationPanel()
  {
    if (this._migrationPanel || this._disableUI) {
      return
    }
    this._migrationPanel = BrazenViewLayer.createMigrationPanel({
      id: 'bv-migration-panel',
      scriptName: this._dockConfig ? this._getDockNameBarText() : '',
    })
    BrazenViewLayer.appendToBody(this._migrationPanel)
  }

  /**
   * @param {{consentRequired: boolean, peerWaitOnly: boolean, schemaTooNew?: boolean, installedSchemaVersion?: number|null, supportedSchemaVersion?: number|null, steps: string[], backupFilename: string|null}} plan
   * @return {Promise<'migrate'|'reset'>}
   * @private
   */
  _awaitMigrationConsent(plan)
  {
    this._ensureMigrationPanel()
    BrazenViewLayer.showMigrationPanel(this._migrationPanel)
    this._migrationPanelActive = true
    return new Promise((resolve) => {
      let settled = false
      let finish = (choice) => {
        if (settled) {
          return
        }
        settled = true
        let buttons = this._migrationPanel.querySelectorAll('.bv-migration-actions .bv-button')
        for (let button of buttons) {
          button.disabled = true
        }
        if (choice === 'migrate') {
          BrazenViewLayer.restoreMigrationProgressShell(this._migrationPanel)
        }
        resolve(choice)
      }
      BrazenViewLayer.showMigrationConsentPanel(this._migrationPanel, {
        steps: plan.steps,
        onStart: () => finish('migrate'),
        onReset: () => finish('reset'),
      })
    })
  }

  /**
   * @param {MigrationProgress} progress
   * @return {Promise<void>}
   * @private
   */
  async _handleMigrationProgress(progress)
  {
    this._ensureMigrationPanel()
    if (!this._migrationPanelActive) {
      BrazenViewLayer.showMigrationPanel(this._migrationPanel)
      BrazenViewLayer.startMigrationPanelElapsedTimer(this._migrationPanel)
      this._migrationPanelActive = true
      await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
    }
    BrazenViewLayer.updateMigrationPanel(this._migrationPanel, progress)
  }

  /**
   * @return {void}
   * @private
   */
  _hideMigrationPanel()
  {
    if (this._forceDockMigrationStatus) {
      return
    }
    if (!this._migrationPanel) {
      return
    }
    BrazenViewLayer.hideMigrationPanel(this._migrationPanel)
    this._migrationPanelActive = false
  }

  /**
   * @param {{schemaTooNew?: boolean, installedSchemaVersion?: number|null, supportedSchemaVersion?: number|null}} plan
   * @return {void}
   * @private
   */
  _showSchemaTooNewPanel(plan)
  {
    this._ensureMigrationPanel()
    BrazenViewLayer.showMigrationPanel(this._migrationPanel)
    this._migrationPanelActive = true
    BrazenViewLayer.showMigrationSchemaTooNewPanel(this._migrationPanel, {
      installedVersion: plan.installedSchemaVersion,
      supportedVersion: plan.supportedSchemaVersion,
      onRetry: () => location.reload(),
      onReset: () => {
        void this.clearScriptDatabase()
      },
    })
  }

  /**
   * @param {Error|*} error
   * @return {void}
   * @private
   */
  _showMigrationFailure(error)
  {
    this._ensureMigrationPanel()
    BrazenViewLayer.showMigrationPanel(this._migrationPanel)
    if (!this._migrationPanelActive) {
      BrazenViewLayer.startMigrationPanelElapsedTimer(this._migrationPanel)
    }
    BrazenViewLayer.showMigrationFailurePanel(this._migrationPanel, {
      backupHint: this._configurationManager.getPreMigrationBackupFilenameHint?.() ??
          'your pre-migration backup zip',
      errorMessage: error?.message ? String(error.message) : String(error),
      onRetry: () => location.reload(),
      onWipe: () => {
        void this.clearScriptDatabase()
      },
    })
    this._migrationPanelActive = true
  }

  /**
   * @deprecated Use migration slide panel during init. Kept for compatibility.
   * @param {string} [message='Migrating database…']
   */
  showDockMigrationStatus(message = 'Migrating database…')
  {
    if (this._disableUI) {
      return
    }
    void this._handleMigrationProgress({phase: 'generic', label: message, indeterminate: true})
  }

  /**
   * @deprecated Use {@link _hideMigrationPanel} during init.
   */
  hideDockMigrationStatus()
  {
    this._hideMigrationPanel()
  }

  /**
   * @deprecated
   * @param {string} [message='Migrating database…']
   * @return {Promise<void>}
   * @private
   */
  async _paintDockMigrationStatus(message = 'Migrating database…')
  {
    await this._handleMigrationProgress({phase: 'generic', label: message, indeterminate: true})
  }

  /**
   * @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)
    if (this._uiSection) {
      BrazenViewLayer.syncSettingsColumnOrder(this._uiSection, orientation)
    }
    this._syncDockPanelPosition()
  }

  /**
   * @param {HTMLElement} panel
   * @private
   */
  _wireComplianceRulesSlidePanel(panel)
  {
    BrazenViewLayer.wireDockSlidePanel(panel, {
      onClose: () => this._hideDockSlidePanel(panel),
    })
  }

  /**
   * Trailing debounce for resize-driven stack sync (maximize/minimize, drag-resize).
   * @private
   */
  _scheduleDockPanelResizeSync()
  {
    if (!this._dockConfig || this._disableUI) {
      return
    }
    if (this._dockPanelResizeSyncTimer != null) {
      clearTimeout(this._dockPanelResizeSyncTimer)
    }
    this._dockPanelResizeSyncTimer = setTimeout(() => {
      this._dockPanelResizeSyncTimer = null
      this._syncDockPanelPositionNow()
    }, 120)
  }

  /**
   * Pin dock-anchored slide panels beside the dock (and stack outward when needed).
   * Order is near-dock → outward: settings adjacent to dock, review panels outward.
   * @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
    }
    if (this._dockPanelSyncInProgress) {
      return
    }
    let dock = document.querySelector('.bv-dock')
    if (!dock) {
      return
    }
    this._dockPanelSyncInProgress = true
    try {
      BrazenViewLayer.syncDockPanelStackHost(dock, this._getDockOrientation(), [
        document.getElementById('bv-ui'),
        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'),
      ].filter(Boolean))
    } finally {
      this._dockPanelSyncInProgress = false
    }
  }

  /**
   * @param {HTMLElement} dock
   * @private
   */
  _observeDockPanelSync(dock)
  {
    if (this._dockPanelResizeObserver) {
      this._dockPanelResizeObserver.disconnect()
      this._dockPanelResizeObserver = null
    }
    if (!dock) {
      return
    }
    this._dockPanelResizeObserver = new ResizeObserver(() => {
      this._scheduleDockPanelResizeSync()
    })
    this._dockPanelResizeObserver.observe(dock)
    let stack = BrazenViewLayer.getDockPanelStack()
    if (stack) {
      this._dockPanelResizeObserver.observe(stack)
    }
  }

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

  /**
   * @param {HTMLElement} panel
   * @protected
   */
  _showDockSlidePanel(panel)
  {
    this._cancelMainPanelHideTimer()
    this._attachSettingsAutoHideBridge(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())
    requestAnimationFrame(() => {
      this._syncDockPanelPositionNow()
      try {
        let stack = BrazenViewLayer.getDockPanelStack()
        if (stack) {
          this._dockPanelResizeObserver?.observe(stack)
        }
      } catch (_) { /* ignore */ }
    })
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @param {HTMLElement} panel
   * @param {boolean} [animate]
   * @protected
   */
  _hideDockSlidePanel(panel, animate = true)
  {
    if (panel?.id === 'bv-ui') {
      BrazenViewLayer.closeSettingsDetailPane()
    }
    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, detail?: {tags?: string[], fieldKeys?: string[]}|null}} event
   * @private
   */
  _scheduleConfigurationChange(event)
  {
    if (event?.source === 'ledger') {
      this._ledgerComplianceDirty = true
    }
    let pending = this._pendingConfigurationChange
    if (pending) {
      let detail = this._configurationManager.mergeChangeDetails(pending.detail, event?.detail)
      let source = event?.source ?? 'all'
      if (pending.source !== source && source !== 'all') {
        // Preserve ledger dirty when coalescing ledger + queue/state into `all`.
        if (pending.source === 'ledger' || event?.source === 'ledger') {
          this._ledgerComplianceDirty = true
        }
        source = 'all'
      } else if (pending.source === 'all') {
        source = 'all'
      }
      event = {
        manager: event?.manager ?? pending.manager,
        source,
        local: event?.local !== false,
        detail,
      }
    }
    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 {{tags?: string[], fieldKeys?: string[]}|null|undefined} detail
   * @return {boolean}
   * @private
   */
  _tagDetailTouchesCompliance(detail)
  {
    let fieldKeys = detail?.fieldKeys
    if (!Array.isArray(fieldKeys) || !fieldKeys.length) {
      // Unknown tag fan-out — keep prior behaviour (re-validate).
      return true
    }
    return fieldKeys.some((fieldKey) =>
        fieldKey === FILTER_TAG_BLACKLIST || fieldKey === 'explored-tags-tracker')
  }

  /**
   * @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} detail
   * @return {boolean}
   * @private
   */
  _tagDetailNeedsDockRefresh(detail)
  {
    let fieldKeys = detail?.fieldKeys
    if (!Array.isArray(fieldKeys) || !fieldKeys.length) {
      return true
    }
    // Pure download-path attribute toggles do not change dock chrome.
    return fieldKeys.some((fieldKey) =>
        fieldKey !== 'filename-tag-ignore-list' && fieldKey !== 'filename-tag-substitutions')
  }

  /**
   * @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean, detail?: {tags?: string[], fieldKeys?: string[]}|null}} event
   * @return {Promise<void>}
   * @private
   */
  async _handleConfigurationChange(event)
  {
    let manager = event.manager ?? this._configurationManager
    let source = event.source ?? 'all'
    let local = event.local !== false
    let detail = event.detail ?? null
    // 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.
    let refreshDock = !runtimePipeline &&
        (source !== 'tags' || !local || this._tagDetailNeedsDockRefresh(detail))
    if (refreshDock) {
      this._refreshDockButtonStates()
    }
    if (reloadsSettingsControls || (source === 'tags' && this._tagDetailTouchesCompliance(detail))) {
      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()
      this._scheduleDownloadLedgerToolboxCountRefresh()
    }
    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 compliance rule counts.
      void this._runComplianceValidation(false)
    }, 400)
  }

  /**
   * Debounced Hide Downloaded refresh when the tab regains focus or visibility.
   * @private
   */
  _wireHideDownloadedFocusVisibilityResync()
  {
    this._hideDownloadedResyncAbort?.abort()
    let controller = new AbortController()
    this._hideDownloadedResyncAbort = controller
    let resync = () => {
      if (document.visibilityState !== 'visible') {
        return
      }
      this._scheduleLedgerComplianceRefresh()
    }
    document.addEventListener('visibilitychange', resync, {signal: controller.signal})
    window.addEventListener('focus', resync, {signal: controller.signal})
  }

  /**
   * @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()
          }
        })
      },
      getRuleColor: (filterKey, ruleLabel) => this._getComplianceRuleColor(filterKey, ruleLabel),
    })
  }

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

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {boolean}
   * @protected
   */
  _canRemoveComplianceRule(filterKey, ruleLabel)
  {
    if (!this._isRemovableTagComplianceFilterKey(filterKey)) {
      return false
    }
    return this._configurationManager.matchesTagRuleLabel(filterKey, ruleLabel,
        this._removableTagComplianceFilters?.normalizeRuleLine)
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {void|Promise<void>}
   * @protected
   */
  async _removeComplianceRule(filterKey, ruleLabel)
  {
    if (!this._isRemovableTagComplianceFilterKey(filterKey)) {
      return
    }

    let field = this._configurationManager.getField(filterKey)
    if (!field) {
      return
    }

    let normalize = this._removableTagComplianceFilters?.normalizeRuleLine
    if (typeof normalize !== 'function') {
      return
    }
    await this._configurationManager.removeTagRuleByLabel(filterKey, ruleLabel, normalize)
  }

  /**
   * Optional name color for an Active Hide Rules row (CSS color string).
   * Return null/empty for non-tag filters and untyped rules — never gates row visibility.
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {string|null}
   * @protected
   */
  _getComplianceRuleColor(filterKey, ruleLabel)
  {
    if (!this._isRemovableTagComplianceFilterKey(filterKey)) {
      return null
    }
    let normalize = this._removableTagComplianceFilters?.normalizeRuleLine
    if (typeof normalize !== 'function') {
      return null
    }
    let normalizedLabel = normalize(ruleLabel)
    if (!normalizedLabel || normalizedLabel.includes(' & ')) {
      return null
    }
    let getColor = this._removableTagComplianceFilters?.getRuleColor
    return typeof getColor === 'function' ? (getColor(filterKey, normalizedLabel) ?? null) : null
  }

  /**
   * @param {string} filterKey
   * @return {boolean}
   * @private
   */
  _isRemovableTagComplianceFilterKey(filterKey)
  {
    return !!this._removableTagComplianceFilters?.fieldKeys?.has(filterKey)
  }

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

  /**
   * @private
   */
  _onBackupSettings()
  {
    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
   */
  _showIdbRequiredFatal()
  {
    this._ensureMigrationPanel()
    BrazenViewLayer.showMigrationPanel(this._migrationPanel)
    BrazenViewLayer.showMigrationFailurePanel(this._migrationPanel, {
      errorMessage: 'This script requires IndexedDB and cannot run without it.',
      onRetry: () => location.reload(),
    })
    this._migrationPanelActive = true
  }

  /**
   * @protected
   * @deprecated Use {@link _showIdbRequiredFatal}
   */
  _showIdbBlockedBanner()
  {
    this._showIdbRequiredFatal()
  }

  /**
   * @private
   */
  async _onSaveSettings()
  {
    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').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.PAGINATION_LIMIT)
    this._configurationManager.addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000).
        setTitle('Pagination Threshold').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.PAGINATION_THRESHOLD)
  }

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

  /**
   * Defer the first compliance pass until after the dock and settings panel paint.
   * @protected
   */
  _scheduleInitialCompliance()
  {
    let run = () => this._validateCompliance(true)
    if (typeof requestIdleCallback === 'function') {
      requestIdleCallback(run, {timeout: 250})
    } else {
      setTimeout(run, 0)
    }
  }

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

    let plan = await this._configurationManager.getPendingMigrationPlan()
    if (plan.idbUnavailable) {
      if (!this._disableUI) {
        this._showIdbRequiredFatal()
      }
      return
    }
    if (plan.schemaTooNew) {
      if (!this._disableUI) {
        this._showSchemaTooNewPanel(plan)
      }
      return
    }
    if (plan.consentRequired && !this._disableUI) {
      let choice = await this._awaitMigrationConsent(plan)
      if (choice === 'reset') {
        await this.clearScriptDatabase()
        return
      }
    }

    try {
      await this._configurationManager.initialize({
        onMigrationProgress: (progress) => this._handleMigrationProgress(progress),
      })
    } catch (error) {
      console.log('[Brazen] migration failed:', error)
      let schemaConflict = await this._configurationManager.resolveSchemaVersionConflictFromError?.(error)
      if (schemaConflict && !this._disableUI) {
        this._showSchemaTooNewPanel({
          installedSchemaVersion: schemaConflict.installed,
          supportedSchemaVersion: schemaConflict.supported,
        })
        return
      }
      if (!this._disableUI) {
        this._showMigrationFailure(error)
      }
      return
    }
    this._hideMigrationPanel()

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

    await this._configurationManager.ensureRulesetFieldsCompiled()

    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(this._mountSettingsUserInterface())
      Utilities.processEventHandlerQueue(this._onAfterUIBuild)

      this._configurationManager.updateInterface()

      if (this._forceDockMigrationStatus) {
        this._ensureMigrationPanel()
        BrazenViewLayer.showMigrationPanel(this._migrationPanel)
        BrazenViewLayer.updateMigrationPanel(this._migrationPanel, {
          phase: 'generic',
          label: 'Updating database…',
          detail: 'Debug: forced migration panel',
          indeterminate: true,
        })
        this._migrationPanelActive = true
      }
    }

    // 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._scheduleInitialCompliance()

    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
  }

  /**
   * Fired when the settings secondary detail pane opens/closes or tag inputs focus changes.
   * @param {Function} handler `(isOpen, { activeTagInputs }) => void`
   * @return {BrazenFramework}
   */
  onSettingsDetailPaneChange(handler)
  {
    if (typeof handler === 'function') {
      this._settingsDetailPaneListeners.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 notifies `tags` with detail — gated chrome refresh follows.
        await this._configurationManager.clearTagSubstitution(normalized, fieldKey)
        break
      }
      case 'linkingTarget': {
        let source = this._tagSubstitutionLinkSource
        let sourceType = this._tagSubstitutionLinkSourceType
        this._tagSubstitutionLinkSource = null
        this._tagSubstitutionLinkSourceType = null
        // Link-mode cleared above; tags notify after persist refreshes subject + alias rows.
        if (source) {
          await this._configurationManager.setTagSubstitution(source, normalized, fieldKey, {
            subjectTypeName: sourceType,
            replacementTypeName: tag.type ?? null,
            source: options.source ?? 'tag-action',
          })
        } else {
          console.log('[BrazenFramework] tag substitution commit skipped: link source missing')
        }
        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
    let pending = false
    return BrazenViewLayer.createTagSubstitutionActionButton({
      state: this.getTagSubstitutionUiState(tagName, {normalize, fieldKey}),
      className: options.className,
      iconClass: options.iconClass,
      onClick: () => {
        if (pending) {
          return
        }
        pending = true
        // Link-mode notify runs sync; commit/clear await IDB — refresh after the step settles.
        let toggle = this.toggleTagSubstitutionLink(tagName, {
          normalize,
          fieldKey,
          tag: options.tag ?? {name: tagName},
        })
        void toggle.finally(() => {
          pending = false
          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 ruleset sole-attribute row (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 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.refreshTagComplianceSpecs([fieldKey])
        }
        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
    let setIconActive = (buttonEl, isActive) => {
      if (!buttonEl) {
        return
      }
      let nextIcon = kind === 'ignore' ? BrazenViewLayer.createTagIgnoreIcon(isActive, iconClass) :
          kind === 'explore' ? BrazenViewLayer.createTagExploreIcon(isActive, iconClass) :
              BrazenViewLayer.createTagBlacklistIcon(isActive, iconClass)
      let svg = buttonEl.querySelector('svg')
      if (svg) {
        svg.replaceWith(nextIcon)
      } else {
        buttonEl.replaceChildren(nextIcon)
      }
    }
    let titleForActive = (isActive) => isActive ?
        (kind === 'ignore' ? 'Stop ignoring in filenames' :
            kind === 'blacklist' ? 'Stop hiding posts with this tag' :
                'Remove tag from explore list') :
        (kind === 'ignore' ? 'Ignore in filenames' :
            kind === 'blacklist' ? 'Hide posts with the tag' :
                'Add tag to explore list')
    return BrazenViewLayer.createTagAttributeActionButton({
      title: titles[kind] ?? '',
      icon,
      className: options.className ?? 'bv-tag-action-btn',
      onClick: (event) => {
        if (pending) {
          return
        }
        pending = true
        let button = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null
        active = !active
        setIconActive(button, active)
        if (button) {
          button.title = titleForActive(active)
        }
        let persist = this.toggleTagSoleAttribute(tagName, {
          fieldKey,
          normalize,
          ensureOptionKey: options.ensureOptionKey,
          tag: options.tag ?? {name: tagName},
        })
        options.onAfterToggle?.()
        void persist.finally(() => {
          pending = false
          let actual = this.hasTagSoleAttributeActive(tagName, {fieldKey, normalize})
          if (actual !== active) {
            active = actual
            setIconActive(button, active)
            if (button) {
              button.title = titleForActive(active)
            }
          }
          options.onAfterToggle?.()
        })
      },
    })
  }

  /**
   * @param {ConfigurationField} field
   * @return {*[]}
   * @private
   */
  _getBookmarkRulesetRows(field)
  {
    return Array.isArray(field._rulesetRows) ? field._rulesetRows : []
  }

  /**
   * @param {*} entry
   * @param {function(string): string} normalizeUrl
   * @return {string}
   * @private
   */
  _getBookmarkEntryUrl(entry, normalizeUrl)
  {
    let payload = entry?.payload ?? entry
    return normalizeUrl(payload?.url ?? '')
  }

  /**
   * Resolve the persisted bookmark row for a normalized URL. In-memory `field._rulesetRows` only
   * holds the paginated first page, so a bookmark added from another surface (tag shortcut, an
   * earlier page) may not be loaded — fall back to a full-field storage scan so toggle/remove is
   * authoritative regardless of pagination.
   * @param {ConfigurationField} field
   * @param {string} normalizedUrl
   * @param {function(string): string} normalizeUrl
   * @return {Promise<*|null>}
   * @private
   */
  async _findPersistedBookmarkEntry(field, normalizedUrl, normalizeUrl)
  {
    if (!normalizedUrl) {
      return null
    }
    let inMemory = this._getBookmarkRulesetRows(field)
        .find((entry) => !entry._optimistic &&
            this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)
    if (inMemory) {
      return inMemory
    }
    if (!this._configurationManager.isStorageReady()) {
      return null
    }
    try {
      let rows = await this._configurationManager.getRepos().rulesetEntries.listAllForField(field.key)
      return rows.find((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl) ?? null
    } catch (error) {
      console.log('[BrazenFramework] bookmark lookup failed:', error)
      return null
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {{label: string, tags: string, url: string}} payload
   * @param {object} [entryMeta]
   * @return {Promise<*>}
   * @private
   */
  async _persistBookmarkRulesetEntry(field, payload, entryMeta = {})
  {
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get('bookmarks') : null
    if (!template) {
      throw new Error('Bookmarks ruleset template unavailable')
    }
    let ctx = this._configurationManager._buildRulesetTemplateCtx(field)
    return template.persist(payload, ctx, entryMeta)
  }

  /**
   * 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 = this._getBookmarkRulesetRows(field)
    let previousRows = rows.slice()
    let refreshBookmarkUi = () => {
      field.rebuildRulesetTagIndex?.()
      field.updateUserInterface?.()
    }
    let storageReady = this._configurationManager.isStorageReady()
    // Sync-visible state — optimistic row mutate + widget render before any IDB await so tag
    // shortcut stars and the open bookmarks panel flip immediately on click.
    let syncEntry = rows.find((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl) ?? null
    let syncBookmarked = !!syncEntry ||
        (!storageReady && !!field.widget?.isCurrentPageBookmarked(url))
    let removing = syncBookmarked
    try {
      if (removing) {
        if (syncEntry) {
          field._rulesetRows = rows.filter((entry) => entry.entryId !== syncEntry.entryId)
        }
        refreshBookmarkUi()
        if (!storageReady) {
          return
        }
        let existingEntry = await this._findPersistedBookmarkEntry(field, normalizedUrl, normalizeUrl)
        if (!existingEntry) {
          field._rulesetRows = previousRows
          refreshBookmarkUi()
          return
        }
        let entryId = existingEntry.entryId
        if (entryId == null) {
          await field.reload?.()
          refreshBookmarkUi()
          return
        }
        field._rulesetRows = this._getBookmarkRulesetRows(field)
            .filter((entry) => entry.entryId !== entryId)
        refreshBookmarkUi()
        let removeId = Number.isFinite(Number(entryId)) ? Number(entryId) : entryId
        await this._configurationManager.getRepos().rulesetEntries.remove(removeId)
        if (typeof compileRulesetField === 'function') {
          await compileRulesetField(this._configurationManager.getRepos(), field.key)
        }
        await field.getOptimized?.()
        if (typeof RulesetMutationBus !== 'undefined') {
          RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [removeId]})
        }
        this._configurationManager.notifyConfigurationChange(field.key, true)
        refreshBookmarkUi()
        return
      }
      if (options.requireTagsToAdd !== false && !tags) {
        return
      }
      if (rows.some((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)) {
        return
      }
      let label = formatLabel(tags)
      let optimisticId = typeof crypto !== 'undefined' && crypto.randomUUID ?
          crypto.randomUUID() :
          String(Date.now()) + Math.random()
      let optimistic = {
        entryId: optimisticId,
        payload: {label, tags, url},
        rawLine: label,
        sortOrder: 0,
        _optimistic: true,
      }
      field._rulesetRows = [optimistic, ...rows]
      refreshBookmarkUi()
      if (!storageReady) {
        return
      }
      let existingEntry = await this._findPersistedBookmarkEntry(field, normalizedUrl, normalizeUrl)
      if (existingEntry) {
        // Off-page row — toggle means remove, not add.
        field._rulesetRows = previousRows.filter((entry) => entry.entryId !== existingEntry.entryId)
        refreshBookmarkUi()
        let entryId = existingEntry.entryId
        if (entryId == null) {
          await field.reload?.()
          refreshBookmarkUi()
          return
        }
        let removeId = Number.isFinite(Number(entryId)) ? Number(entryId) : entryId
        await this._configurationManager.getRepos().rulesetEntries.remove(removeId)
        if (typeof compileRulesetField === 'function') {
          await compileRulesetField(this._configurationManager.getRepos(), field.key)
        }
        await field.getOptimized?.()
        if (typeof RulesetMutationBus !== 'undefined') {
          RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [removeId]})
        }
        this._configurationManager.notifyConfigurationChange(field.key, true)
        refreshBookmarkUi()
        return
      }
      // Resolve tag types before persisting the row. Shortcut (single-tag) bookmarks carry
      // the row type directly; multi-tag search bookmarks defer to the consumer's optional
      // `resolveTagTypes(tags, ctx)` hook (e.g. sidebar/registry/search lookup by their scheme).
      await this._resolveBookmarkTagTypes(options, tags, url)
      let row = await this._persistBookmarkRulesetEntry(field, {label, tags, url}, {sortOrder: 0})
      field._rulesetRows = field._rulesetRows.filter((entry) => !entry._optimistic)
      field.patchRow?.(row)
      let sorted = await this._configurationManager._applyRulesetAutoSortIfEnabled(field)
      if (!sorted) {
        if (typeof compileRulesetField === 'function') {
          await compileRulesetField(this._configurationManager.getRepos(), field.key)
        }
        await field.getOptimized?.()
      }
      if (typeof RulesetMutationBus !== 'undefined') {
        RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [row.entryId]})
      }
      this._configurationManager.notifyConfigurationChange(field.key, true)
      refreshBookmarkUi()
    } catch (error) {
      field._rulesetRows = previousRows
      refreshBookmarkUi()
      throw error
    } finally {
      this._tagAttributePersistKeys.delete(persistKey)
    }
  }

  /**
   * Resolve tag types into the registry for a bookmark being added. Combines the direct
   * `ensureTypedTags` (single-tag shortcut) with the consumer's optional `resolveTagTypes`
   * hook for multi-tag search bookmarks. The hook is site-specific (sidebar scrape, registry
   * lookup, remote search, etc.); it receives the raw tags string and a context and returns
   * `{name, type}[]` (or a promise of one). Failures are swallowed so a bookmark never blocks.
   *
   * @param {{normalize?: function(string): string, ensureTypedTags?: {name: string, type?: string|null}[], resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>)}} options
   * @param {string} tags Raw (space-joined) bookmark tags.
   * @param {string} url Bookmark URL.
   * @return {Promise<void>}
   * @private
   */
  async _resolveBookmarkTagTypes(options, tags, url)
  {
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let typedTags = []
    if (Array.isArray(options.ensureTypedTags)) {
      typedTags.push(...options.ensureTypedTags)
    }
    if (typeof options.resolveTagTypes === 'function') {
      try {
        let resolved = await options.resolveTagTypes(tags, {url, normalize})
        if (Array.isArray(resolved)) {
          typedTags.push(...resolved)
        }
      } catch (error) {
        console.log('[BrazenFramework] resolveTagTypes failed:', error)
      }
    }
    for (let typedTag of typedTags) {
      if (typedTag?.name && typedTag?.type != null) {
        await this._ensureTagEntityForAction(typedTag, normalize, 'bookmark')
      }
    }
  }

  /**
   * Refresh tag-action chrome only on surfaces that reference the changed tags.
   * Without detail.tags, all provided handlers run (safe full refresh).
   *
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @param {{
   *   refreshSidebarRows?: function(string[]|null): void,
   *   refreshBookmarks?: function(): void,
   *   refreshDiscovery?: function(string[]|null): void,
   *   refreshDock?: function(): void,
   *   sidebarContainsTag?: function(string): boolean,
   *   discoveryContainsAnyTag?: function(string[]): boolean,
   * }} [handlers]
   */
  refreshTagActionSurfaces(detail = null, handlers = {})
  {
    let tags = Array.isArray(detail?.tags) ? detail.tags.filter(Boolean) : null
    let scoped = !!(tags && tags.length)

    let sidebarTags = null
    if (!scoped) {
      handlers.refreshSidebarRows?.(null)
    } else if (typeof handlers.sidebarContainsTag === 'function') {
      sidebarTags = tags.filter((tag) => handlers.sidebarContainsTag(tag))
      if (sidebarTags.length) {
        handlers.refreshSidebarRows?.(sidebarTags)
      }
    } else {
      handlers.refreshSidebarRows?.(tags)
    }

    if (!scoped || this._configurationManager.rulesetContainsAnyTag(tags)) {
      handlers.refreshBookmarks?.()
    }

    if (!scoped) {
      handlers.refreshDiscovery?.(null)
    } else if (typeof handlers.discoveryContainsAnyTag !== 'function' ||
        handlers.discoveryContainsAnyTag(tags)) {
      handlers.refreshDiscovery?.(tags)
    }

    if (!scoped || this._tagDetailNeedsDockRefresh(detail)) {
      handlers.refreshDock?.()
    }
  }

  /**
   * @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 || !options.buildUrl) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
    let url = options.buildUrl(normalize(tagName))
    let normalizedUrl = normalizeUrl(url)
    if (!normalizedUrl) {
      return false
    }
    let rows = this._getBookmarkRulesetRows(field)
    if (rows.some((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)) {
      return true
    }
    return field.widget?.isCurrentPagePresent?.(url) ?? false
  }

  /**
   * Whether the current search page URL is bookmarked (normalized URL identity).
   * @param {{
   *   fieldKey?: string,
   *   getCurrentUrl?: function(): string,
   *   normalizeUrl?: function(string): string,
   * }} [options]
   * @return {boolean}
   */
  isCurrentSearchBookmarked(options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    if (!field) {
      return false
    }
    let getCurrentUrl = options.getCurrentUrl ?? (() => location.href)
    let url = getCurrentUrl()
    let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
    let normalizedUrl = normalizeUrl(url)
    if (!normalizedUrl) {
      return false
    }
    let rows = this._getBookmarkRulesetRows(field)
    if (rows.some((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)) {
      return true
    }
    return field.widget?.isCurrentPagePresent?.(url) ?? false
  }

  /**
   * 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,
   *   tag?: {name: string, type?: string|null},
   *   resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
   * }} options
   * @return {Promise<void>}
   */
  async toggleTagSearchBookmark(tagName, options = {})
  {
    if (!options.buildUrl) {
      return
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let tags = normalize(tagName)
    // Single-tag shortcut bookmarks carry a known type — resolve it into the registry so
    // the bookmarked tag colorizes/classifies (search-box URLs have no per-tag type).
    let typedTag = options.tag ?? {name: tagName}
    return this._toggleBookmarkAtUrl({
      fieldKey: options.fieldKey ?? 'bookmarks',
      url: options.buildUrl(tags),
      tags,
      formatLabel: options.formatLabel,
      normalizeUrl: options.normalizeUrl,
      requireTagsToAdd: true,
      ensureTypedTags: typedTag?.type != null ? [typedTag] : null,
      resolveTagTypes: options.resolveTagTypes,
      normalize,
    })
  }

  /**
   * 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,
   *   normalize?: function(string): string,
   *   resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
   * }} [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,
      normalize: options.normalize,
      resolveTagTypes: options.resolveTagTypes,
      requireTagsToAdd: true,
    })
  }

  /**
   * Enable one-click remove + optional row colors for tag ruleset compliance filters (Active Hide Rules).
   * @param {{
   *   fieldKeys: string[],
   *   normalizeRuleLine: function(string): string,
   *   getRuleColor?: function(string, string): (string|null),
   * }} options
   */
  registerRemovableTagComplianceFilters(options)
  {
    let fieldKeys = options?.fieldKeys
    if (!Array.isArray(fieldKeys) || !fieldKeys.length || typeof options.normalizeRuleLine !== 'function') {
      return
    }
    this._removableTagComplianceFilters = {
      fieldKeys: new Set(fieldKeys),
      normalizeRuleLine: options.normalizeRuleLine,
      getRuleColor: options.getRuleColor,
    }
  }

  /**
   * 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,
   *   help?: string,
   *   getCurrentUrl?: function(): string,
   *   getTags?: function(): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   normalize?: function(string): string,
   *   resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
   *   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,
      normalize: options.normalize,
      resolveTagTypes: options.resolveTagTypes,
    }
    return this._configurationManager.addActionField(actionKey).
        setTitle(options.title ?? 'Bookmark Search').
        setHelp(options.help ?? FRAMEWORK_FIELD_DETAILED_HELP.BOOKMARK_SEARCH).
        setAction(() => {
          // Optimistic row mutate + widget.render run sync before the first await.
          let persist = this.toggleCurrentSearchBookmark(bookmarkOptions)
          this._refreshDockButtonStates()
          void persist.finally(() => 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,
        })
  }

  /**
   * Hide search tiles whose post id is below {@link OPTION_LAST_ID} when enabled.
   * @param {{optionKey?: string, lastIdKey?: string, getPostId: function(HTMLElement): *, dockTemplate?: string}} options
   * @return {BrazenFramework}
   */
  registerHideOlderPostsFilter(options)
  {
    if (typeof options?.getPostId !== 'function') {
      throw new Error('registerHideOlderPostsFilter() requires getPostId(item).')
    }
    let optionKey = options.optionKey ?? OPTION_HIDE_OLDER_POSTS
    let lastIdKey = options.lastIdKey ?? OPTION_LAST_ID
    let getPostId = options.getPostId

    this._configurationManager.addFlagField(optionKey).
        setTitle('Hide Older Posts').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.HIDE_OLDER_POSTS).
        applyDockTemplate(options.dockTemplate ?? 'hideOlderPosts')

    this._configurationManager.addNumberField(lastIdKey, 0, Number.MAX_SAFE_INTEGER).
        setTitle('Last ID').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.LAST_ID)

    this._addItemComplexComplianceFilter(
        optionKey,
        (enabled) => enabled && this._getConfig(lastIdKey) > 0,
        (item) => {
          let id = getPostId(item)
          let lastId = this._getConfig(lastIdKey)
          if (id === null || id === undefined || id >= lastId) {
            return true
          }
          return {complies: false, rule: 'Post id < ' + lastId}
        },
    )
    return this
  }

  /**
   * Dock slide-out actions that capture Last ID from the current search page or media post.
   * @param {{
   *   optionKey?: string,
   *   searchActionKey?: string,
   *   mediaActionKey?: string,
   *   onSetLatestFromSearch: function(): void,
   *   onSetLatestFromMedia: function(): void,
   *   searchInclude?: function(): boolean,
   *   mediaInclude?: function(): boolean,
   *   hideOlderInclude?: function(): boolean,
   * }} options
   * @return {BrazenFramework}
   */
  registerHideOlderPostsDockActions(options)
  {
    if (typeof options?.onSetLatestFromSearch !== 'function' ||
        typeof options?.onSetLatestFromMedia !== 'function') {
      throw new Error('registerHideOlderPostsDockActions() requires onSetLatestFromSearch and onSetLatestFromMedia.')
    }
    let optionKey = options.optionKey ?? OPTION_HIDE_OLDER_POSTS
    let searchActionKey = options.searchActionKey ?? DOCK_SET_LATEST_ID
    let mediaActionKey = options.mediaActionKey ?? DOCK_SET_POST_ID

    this._configurationManager.addActionField(searchActionKey).
        setTitle('Set Latest ID').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.SET_LATEST_ID).
        setAction(() => options.onSetLatestFromSearch()).
        setDockButton({
          icon: 'target',
          tooltip: 'Capture highest post id as Last ID',
          include: options.searchInclude,
        })

    this._configurationManager.addActionField(mediaActionKey).
        setTitle('Set Post ID').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.SET_POST_ID).
        setAction(() => options.onSetLatestFromMedia()).
        setDockButton({
          icon: 'target',
          tooltip: 'Capture this post id as Last ID',
          include: options.mediaInclude,
        })

    this._configurationManager.getField(optionKey)?.
        setDockSlideOut([searchActionKey, mediaActionKey]).
        setDockButton({
          include: options.hideOlderInclude,
        })
    return this
  }

  /**
   * When enabled, advance to the next search page after compliance hides every tile on the current page.
   * @param {{optionKey?: string, dockTemplate?: string, nextPageSelector?: string, pageGuard?: function(): boolean, onAfterCompliance?: function(): void}} [options]
   * @return {BrazenFramework}
   */
  registerAutoNextPageFilter(options = {})
  {
    let optionKey = options.optionKey ?? OPTION_AUTO_NEXT_PAGE
    this._configurationManager.addFlagField(optionKey).
        setTitle('Auto Next Page').
        setHelp(FRAMEWORK_FIELD_DETAILED_HELP.AUTO_NEXT_PAGE).
        applyDockTemplate(options.dockTemplate ?? 'autoNextPage')

    if (typeof options.onAfterCompliance === 'function') {
      this._onAfterComplianceRun.push(options.onAfterCompliance)
    } else if (options.nextPageSelector) {
      let selector = options.nextPageSelector
      let pageGuard = options.pageGuard
      this._onAfterComplianceRun.push(() => {
        if (typeof pageGuard === 'function' && !pageGuard.call(this)) {
          return
        }
        if (this._getConfig(optionKey) && document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM).length === 0) {
          document.querySelector(selector)?.click()
        }
      })
    }
    return this
  }

  /**
   * Left / right arrow keys navigate paginator prev/next links (skips form fields and modifier keys).
   * @param {{
   *   pages?: string|string[],
   *   backSelector: string,
   *   nextSelector: string,
   * }} options
   * @return {this}
   */
  registerPaginatorKeyboardNav(options = {})
  {
    let backSelector = options.backSelector
    let nextSelector = options.nextSelector
    if (!backSelector || !nextSelector) {
      throw new Error('registerPaginatorKeyboardNav requires backSelector and nextSelector')
    }
    let handler = (event) => {
      if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) {
        return
      }
      let target = event.target
      if (target instanceof HTMLElement &&
          (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) {
        return
      }
      let selector = event.key === 'ArrowLeft' ? backSelector
        : event.key === 'ArrowRight' ? nextSelector
          : null
      if (!selector) {
        return
      }
      let link = document.querySelector(selector)
      if (link) {
        event.preventDefault()
        link.click()
      }
    }
    let setup = () => this._wirePaginatorKeyboardNav(handler)
    if (options.pages) {
      this._forPage(options.pages, setup)
    } else {
      setup()
    }
    return this
  }

  /**
   * @param {function(KeyboardEvent): void} handler
   * @private
   */
  _wirePaginatorKeyboardNav(handler)
  {
    this._paginatorKeyboardNavAbort?.abort()
    let controller = new AbortController()
    this._paginatorKeyboardNavAbort = controller
    document.addEventListener('keydown', handler, {signal: controller.signal})
  }

  /**
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   *   tag?: {name: string, type?: string|null},
   *   resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
   *   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 iconClass = options.iconClass ?? 'bv-tag-action-icon'
    let bookmarked = this.isTagSearchBookmarked(tagName, options)
    let pending = false
    return BrazenViewLayer.createTagAttributeActionButton({
      title: bookmarked ? 'Remove bookmark' : 'Bookmark',
      icon: BrazenViewLayer.createTagBookmarkStarIcon(bookmarked, iconClass),
      className: options.className ?? 'bv-tag-action-btn',
      onClick: (event) => {
        if (pending) {
          return
        }
        pending = true
        let button = event.currentTarget
        bookmarked = !bookmarked
        BrazenViewLayer.setTagBookmarkStarFilled(button, bookmarked, iconClass)
        button.title = bookmarked ? 'Remove bookmark' : 'Bookmark'
        // Row mutate + widget.render run sync before the first await; rebuild chrome after.
        let persist = this.toggleTagSearchBookmark(tagName, options)
        options.onAfterToggle?.()
        void persist.finally(() => {
          pending = false
          let actual = this.isTagSearchBookmarked(tagName, options)
          if (actual !== bookmarked) {
            bookmarked = actual
            BrazenViewLayer.setTagBookmarkStarFilled(button, bookmarked, iconClass)
            button.title = bookmarked ? 'Remove bookmark' : 'Bookmark'
          }
          options.onAfterToggle?.()
        })
      },
    })
  }

  /**
   * Append framework-owned tag attribute actions (bookmark, ignore, substitute, blacklist, explore).
   * Consumer supplies incidence options (`buildUrl`, field keys, CSS classes, ensureOptionKey).
   * @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, resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>)}|false,
   *   ignore?: {fieldKey?: string}|false,
   *   substitute?: {fieldKey?: string}|false,
   *   blacklist?: {fieldKey?: string, ensureOptionKey?: string}|false,
   *   explore?: {fieldKey?: string, ensureOptionKey?: string}|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,
        // Carry the row's tag type so a shortcut bookmark resolves it into the registry.
        tag,
        className,
        iconClass: config.bookmark.iconClass ?? iconClass,
        // Bookmark toggles notify `bookmarks`, not `tags` — allow a dedicated after hook.
        onAfterToggle: config.bookmark.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'
      if (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-ruleset-panel-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)
        // Sole check also covers cache-miss via hasTagSoleAttribute → ruleset fallback.
        let soleRemovable = tokens.length === 1 && this.hasTagSoleAttributeActive(tagNames[0], {
          fieldKey: blacklistKey,
          normalize,
        })
        if (!verdict.complies || soleRemovable) {
          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 && tagNames.length) {
      let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
      if (this._configurationManager.hasField(exploreKey)) {
        let verdict = this._configurationManager.evaluateTagCompliance(tagNames, exploreKey)
        let soleRemovable = tokens.length === 1 && this.hasTagSoleAttributeActive(tagNames[0], {
          fieldKey: exploreKey,
          normalize,
        })
        if (!verdict.complies || soleRemovable) {
          if (soleRemovable) {
            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
                })
              },
            }))
          } else {
            actions.push(BrazenViewLayer.createTagAttributeActionButton({
              className,
              title: 'Explored by rule: ' + (verdict.rule ?? 'explore list'),
              icon: BrazenViewLayer.createTagExploreIcon(true, iconClass),
              onClick: () => {},
            }))
          }
        }
      }
    }

    return actions
  }

  /**
   * Prefetch tag cache + compliance specs for visible bookmark rows so attribute
   * status icons do not false-negative on cold cache / late specs. Self-heals via
   * config.onReady when rows or specs newly load. In-flight guards prevent duplicate
   * work; names are re-fetched after eviction / clearCache.
   *
   * @param {Array<{tags?: string}>} bookmarks
   * @param {{
   *   tokenize: function(string): string[],
   *   normalize?: function(string): string,
   *   onReady?: function(): void,
   *   attemptedKey?: string,
   *   blacklist?: {fieldKey?: string}|false,
   *   explore?: {fieldKey?: string}|false,
   * }} 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 inFlight = this._bookmarkRowAttributePrefetchInFlight.get(attemptedKey)
    if (!inFlight) {
      inFlight = new Set()
      this._bookmarkRowAttributePrefetchInFlight.set(attemptedKey, inFlight)
    }

    let fieldKeys = []
    if (config.blacklist !== false) {
      let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
      if (this._configurationManager.hasField(blacklistKey)) {
        fieldKeys.push(blacklistKey)
      }
    }
    if (config.explore !== false) {
      let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
      if (this._configurationManager.hasField(exploreKey)) {
        fieldKeys.push(exploreKey)
      }
    }

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

    let specsMissing = fieldKeys.some((fieldKey) => !this._configurationManager.getTagComplianceSpec(fieldKey))
    let specFlightKey = '__compliance-specs__'
    let refreshingSpecs = specsMissing && !inFlight.has(specFlightKey)
    if (!names.length && !refreshingSpecs) {
      return
    }

    for (let name of names) {
      inFlight.add(name)
    }
    if (refreshingSpecs) {
      inFlight.add(specFlightKey)
    }
    try {
      let specsLoaded = false
      if (refreshingSpecs) {
        await this._configurationManager.refreshTagComplianceSpecs(fieldKeys)
        specsLoaded = fieldKeys.some((fieldKey) => !!this._configurationManager.getTagComplianceSpec(fieldKey))
      }

      let specs = fieldKeys.map((fieldKey) => this._configurationManager.getTagComplianceSpec(fieldKey)).
          filter(Boolean)
      if (itemTagNameLists.length && specs.length) {
        await tagRuntime.ensureComplianceLookups(itemTagNameLists, specs)
      } else if (names.length) {
        await tagRuntime.ensureNames(names)
      }

      let namesLoaded = names.some((name) => this._configurationManager.getCachedTagEntry(name))
      if (specsLoaded || namesLoaded) {
        config.onReady?.()
      }
    } catch (_) {
      return
    } finally {
      for (let name of names) {
        inFlight.delete(name)
      }
      if (refreshingSpecs) {
        inFlight.delete(specFlightKey)
      }
    }
  }

  /**
   * @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,
   *   settingsPanelWidth?: number,
   *   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,
      settingsPanelWidth: typeof config.settingsPanelWidth === 'number' ?
          Math.max(150, Math.round(config.settingsPanelWidth)) :
          undefined,
      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 (!this._configurationManager.getField(OPTION_AUTO_HIDE_SETTINGS_PANE)) {
      this._configurationManager.addFlagField(OPTION_AUTO_HIDE_SETTINGS_PANE).
          setTitle('Auto-Hide Settings Panel').
          setHelp(FRAMEWORK_FIELD_DETAILED_HELP.AUTO_HIDE_SETTINGS_PANE)
    }

    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 {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()
  }

  /**
   * Append post ids to the download duplicate ledger (merge; existing ids kept).
   *
   * @param {Iterable<string|number|null|undefined>} ids
   * @return {Promise<void>}
   */
  async importDownloadLedgerIds(ids)
  {
    let field = this._getDownloadDuplicateLedgerField()
    if (!field) {
      return 0
    }
    let cm = this._configurationManager
    let batch = []
    let written = 0
    let started = false
    let ensureStarted = async () => {
      if (!started) {
        await cm.beginDownloadLedgerFolderImport(false)
        started = true
      }
    }
    for (let raw of ids) {
      if (raw === null || raw === undefined || !String(raw).length) {
        continue
      }
      await ensureStarted()
      batch.push(String(raw).trim())
      if (batch.length >= DOWNLOAD_LEDGER_IMPORT_BATCH_SIZE) {
        written += await cm.mergeDownloadLedgerImportBatch(batch)
        batch = []
        await this._yieldDownloadLedgerImportTurn()
      }
    }
    if (!started) {
      return 0
    }
    if (batch.length) {
      written += await cm.mergeDownloadLedgerImportBatch(batch)
    }
    await cm.finalizeDownloadLedgerFolderImport()
    return written
  }

  /**
   * Replace the download duplicate ledger with the supplied post ids.
   *
   * @param {Iterable<string|number|null|undefined>} ids
   * @return {Promise<number>} count of ids written
   */
  replaceDownloadLedgerIds(ids)
  {
    return this._configurationManager.replaceDownloadLedgerIds(ids)
  }

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

  /**
   * Clear {@link TagEntry.isDiscovered} for all tags (`true` → `null`). Types and rulesets unchanged.
   *
   * @param {function({current?: number, total?: number, label?: string}): void|Promise<void>|null} [onProgress]
   * @return {Promise<{updated: number, total: number}>}
   */
  resetAllTagsDiscovered(onProgress = null)
  {
    return this._configurationManager.resetAllTagsDiscovered(onProgress)
  }

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

/** @type {Readonly<Record<string, string>>} */
BrazenFramework.FIELD_DETAILED_HELP = FRAMEWORK_FIELD_DETAILED_HELP
BrazenFramework.DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN = DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN
BrazenFramework.DEFAULT_DOWNLOAD_PATTERN_SECTIONS = DEFAULT_DOWNLOAD_PATTERN_SECTIONS
BrazenFramework.DEFAULT_LEDGER_IMPORT_LABEL_TO_REGEX = DEFAULT_LEDGER_IMPORT_LABEL_TO_REGEX
BrazenFramework.buildLedgerImportLabelToRegex = buildLedgerImportLabelToRegex
BrazenFramework.rulesetFieldSeedFromSpec = rulesetFieldSeedFromSpec