Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

Цей скрипт не слід встановлювати безпосередньо. Це - бібліотека для інших скриптів для включення в мета директиву // @require https://update.greasyfork.org/scripts/418665/1909478/Brazen%20Framework%20-%20Configuration%20Manager.js

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

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

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

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

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

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

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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

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

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

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

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

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

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

// ==UserScript==
// @name         Brazen Framework - Configuration Manager
// @namespace    brazenvoid
// @version      8.0.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Configuration management for the Brazen user scripts framework
// ==/UserScript==

// @ts-nocheck

const CONFIG_BACKUP_VERSION = 5
/** Legacy-compatible zip shape for pre-migration safety exports (downgrade + restore path). */
const PRE_MIGRATION_BACKUP_VERSION = 3
const CONFIG_TYPE_CHECKBOXES_GROUP = 'checkboxes'
const CONFIG_TYPE_COLOR = 'color'
const CONFIG_TYPE_FLAG = 'flag'
const CONFIG_TYPE_LEDGER = 'ledger'
const CONFIG_TYPE_NUMBER = 'number'
const CONFIG_TYPE_RADIOS_GROUP = 'radios'
const CONFIG_TYPE_RANGE = 'range'
const CONFIG_TYPE_RULESET = 'ruleset'
const CONFIG_TYPE_SELECT = 'select'
const CONFIG_TYPE_TEXT = 'text'
const CONFIG_TYPE_ACTION = 'action'

const CONFIG_TYPE_DEFAULTS = {
  flag: false,
  color: false,
  text: '',
  number: null,
  range: null,
  checkboxes: [],
  select: null,
  radios: null,
}
const RULESET_USER_CONFIG_KEYS = ['autoSort', 'sortMode', 'groupingEnabled', 'hideTagTypes']
const DEFAULT_RULESET_USER_CONFIG = {
  autoSort: true,
  sortMode: 'natural-asc',
  groupingEnabled: false,
  hideTagTypes: false,
}

/**
 * @param {string} subject
 * @return {string}
 */
function pluralizeRulesetSubject(subject)
{
  let word = String(subject ?? 'rule').trim().toLowerCase() || 'rule'
  if (word.endsWith('s')) {
    return word
  }
  if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) {
    return word.slice(0, -1) + 'ies'
  }
  if (/([sxz]|ch|sh)$/.test(word)) {
    return word + 'es'
  }
  return word + 's'
}

/**
 * @param {string} subject
 * @return {string}
 */
function titleCaseRulesetSubject(subject)
{
  let word = String(subject ?? '').trim()
  if (!word) {
    return ''
  }
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}

/** Reactor bus custom-command names (BroadcastChannel via BrazenEventBus). */
const REACTOR_CONFIG_CHANGE = 'config-change'
const REACTOR_RULESET_MUTATION = 'ruleset-mutation'
/** DM runtime IDB stores — high-frequency pipeline writes; not config-domain UI reactions. */
if (!globalThis.__brazenRuntimePipelineConfigSources) {
  globalThis.__brazenRuntimePipelineConfigSources = new Set([
    'downloadResolutionQueue',
    'downloadQueue',
    'downloadManagerState',
  ])
}

class BrazenConfigurationManager
{
  /**
   * @typedef {{key: string, title: string, type: string, element: null|HTMLElement, value: *, maximum: int, minimum: int, options: string[], help: *,
   *            onFormatForUI: ConfigurationManagerRulesetCallback, onTranslateFromUI: ConfigurationManagerRulesetCallback,
   *            onOptimize: ConfigurationManagerRulesetCallback, onMatchRule?: ConfigurationManagerRulesetMatchCallback,
   *            createElement: Function, setFromUserInterface: Function, updateUserInterface: Function,
   *            optimized?: *, persist?: boolean, widget?: *, storage?: string, sortRules?: boolean,
   *            setRules?: function(*): void, findRuleIndex?: function(*): number, hasRule?: function(*): boolean,
   *            addRule?: function(*, *=): void, removeRule?: function(*): void, toggleRule?: function(*, *=): void,
   *            clearRules?: function(): void,
   *            serializeForBackup?: function(): *, applyFromBackup?: function(*): void}} ConfigurationField
   */

  /**
   * @callback ConfigurationManagerRulesetCallback
   * @param {*} values
   */

  /**
   * @callback ConfigurationManagerRulesetMatchCallback
   * @param {*} storedRule
   * @param {*} target
   * @return {boolean}
   */

  /**
   * @callback ExternalConfigurationChangeCallback
   * @param {BrazenConfigurationManager} manager
   */

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

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

  /**
   * @type {Map<string, {value: *, optimized: *, rev: number}>}
   * @private
   */
  _settingCache = new Map()

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

  /** @type {object|null} BrazenEventBus instance */
  _configBus = null

  /** @type {Function|null} */
  _configBusUnsubscribe = null

  /** @type {((command: object) => Promise<void>)|null} Reactor Command sender (Framework wires). */
  _commandSender = null

  /** @type {((event: {source?: string, local?: boolean, detail?: *}) => void)|null} */
  _configUiReactionDelegate = null

  /** @type {((source: string) => void)|null} Read-only DM UI refresh on runtime pipeline store writes. */
  _runtimePipelineChangeDelegate = null

  /** @type {object|null} createAtom revision counter */
  _configRevisionAtom = null

  /** @type {object|null} */
  _ledgerRevisionAtom = null

  /** @type {object|null} */
  _tagsRevisionAtom = null

  /** @type {Set<string>} Keys cached immediately before the next config revision bump. */
  _settingCacheWriteKeys = new Set()

  /**
   * @type {string|null}
   * @private
   */
  _lastFieldKey = null

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

  /**
   * User-script instance used to evaluate dock `include` callbacks.
   * @type {object|null}
   * @private
   */
  _dockIncludeContext = null

  /**
   * @type {HTMLElement[]}
   * @private
   */
  _dockElements = []

  /**
   * @type {Error|null}
   * @private
   */
  _migrationError = null

  /**
   * @type {function(*): (void|Promise<void>)|null}
   * @private
   */
  _reportMigrationProgress = null

  /**
   * @return {Error|null}
   */
  getMigrationError()
  {
    return this._migrationError
  }

  /**
   * @param {function(MigrationProgress|string): void|Promise<void>|null|undefined} callback
   * @return {function(MigrationProgress|string): Promise<void>}
   * @private
   */
  _bindMigrationProgress(callback)
  {
    if (typeof callback !== 'function') {
      return async () => {}
    }
    return async (progress) => {
      let normalized = typeof progress === 'string'
          ? {phase: 'generic', label: progress, indeterminate: true}
          : progress
      await callback(normalized)
    }
  }

  /**
   * @param {object|null} meta
   * @return {Promise<boolean>}
   * @private
   */
  async _isMigrationWorkNeeded(meta = null)
  {
    meta = meta ?? await this._repos.meta.get()
    if (meta?.setupInProgress) {
      return true
    }
    let localWork = await this._getLocalMigrationWork(meta)
    return localWork.hasLocalWork
  }

  /**
   * @param {object|null} meta
   * @return {Promise<{schemaRebuild: boolean, backfill: boolean, rulesetMigration: boolean, hasLocalWork: boolean}>}
   * @private
   */
  async _getLocalMigrationWork(meta = null)
  {
    meta = meta ?? await this._repos.meta.get()
    let schemaRebuild = !await this._repos.storage.isHealthy()
    let backfill = !!meta?.pendingIsDiscoveredBackfill
    let rulesetConfigDefaultsV9 = !!meta?.pendingRulesetConfigDefaultsV9
    let rulesetHideTagTypesV10 = !!meta?.pendingRulesetHideTagTypesResetV10
    let rulesetMigration = !meta?.rulesetMigrated
    let bookmarksMigration = !meta?.bookmarksMigrated
    return {
      schemaRebuild,
      backfill,
      rulesetConfigDefaultsV9,
      rulesetHideTagTypesV10,
      rulesetMigration,
      bookmarksMigration,
      hasLocalWork: schemaRebuild || backfill || rulesetConfigDefaultsV9 ||
          rulesetHideTagTypesV10 || rulesetMigration || bookmarksMigration,
    }
  }

  /**
   * @return {Promise<{installed: number, supported: number}|null>}
   */
  async getSchemaVersionConflict()
  {
    if (!this._repos.storage.available) {
      return null
    }
    return this._repos.storage.getSchemaVersionConflict()
  }

  /**
   * @param {Error|*} error
   * @return {Promise<{installed: number, supported: number}|null>}
   */
  async resolveSchemaVersionConflictFromError(error)
  {
    if (error?.code === 'SCHEMA_TOO_NEW' && error.schemaConflict) {
      return error.schemaConflict
    }
    if (this._repos.storage.isVersionError?.(error)) {
      return this.getSchemaVersionConflict()
    }
    return null
  }

  /**
   * @return {Promise<{consentRequired: boolean, peerWaitOnly: boolean, schemaTooNew: boolean, installedSchemaVersion: number|null, supportedSchemaVersion: number|null, steps: string[], backupFilename: string|null}>}
   */
  async getPendingMigrationPlan()
  {
    let emptyPlan = {
      consentRequired: false,
      peerWaitOnly: false,
      schemaTooNew: false,
      idbUnavailable: false,
      installedSchemaVersion: null,
      supportedSchemaVersion: null,
      steps: [],
      backupFilename: null,
    }
    if (!this._repos.storage.available) {
      return {...emptyPlan, idbUnavailable: true}
    }
    let wipeFlagKey = this._scriptPrefix + 'pending-idb-wipe'
    try {
      if (sessionStorage.getItem(wipeFlagKey) === '1') {
        return emptyPlan
      }
    } catch (e) {
    }
    let schemaConflict = await this.getSchemaVersionConflict()
    if (schemaConflict) {
      return {
        ...emptyPlan,
        schemaTooNew: true,
        installedSchemaVersion: schemaConflict.installed,
        supportedSchemaVersion: schemaConflict.supported,
      }
    }
    try {
      await this._repos.storage.open()
    } catch (error) {
      let conflict = await this.resolveSchemaVersionConflictFromError(error)
      if (conflict) {
        return {
          ...emptyPlan,
          schemaTooNew: true,
          installedSchemaVersion: conflict.installed,
          supportedSchemaVersion: conflict.supported,
        }
      }
      throw error
    }
    let meta = await this._repos.meta.get()
    if (!(await this._isMigrationWorkNeeded(meta))) {
      return emptyPlan
    }
    let localWork = await this._getLocalMigrationWork(meta)
    if (meta?.setupInProgress && !localWork.hasLocalWork) {
      return {...emptyPlan, peerWaitOnly: true}
    }
    let backupFilename = this._scriptPrefix + 'pre-migration-backup.zip'
    let steps = [
      'Download a safety backup (' + backupFilename + ') to your Downloads folder.',
    ]
    let migrationParts = []
    if (localWork.schemaRebuild) {
      migrationParts.push('rebuild the database schema')
    }
    if (localWork.backfill) {
      migrationParts.push('backfill tag metadata')
    }
    if (localWork.rulesetConfigDefaultsV9) {
      migrationParts.push('apply ruleset configuration defaults')
    }
    if (localWork.rulesetMigration) {
      migrationParts.push('migrate tag rules')
    }
    steps.push(
        migrationParts.length
            ? 'Update the local database (' + migrationParts.join(', ') + ').'
            : 'Update the local database (settings, tags, bookmarks, and download history).',
    )
    steps.push('Finish loading the script.')
    return {
      ...emptyPlan,
      consentRequired: true,
      steps,
      backupFilename,
    }
  }

  /**
   * @param {function(MigrationProgress|string): Promise<void>} report
   * @return {Promise<void>}
   * @private
   */
  async _waitPeerSetupWithProgress(report)
  {
    let started = Date.now()
    while (true) {
      let meta = await this._repos.meta.get()
      if (meta?.setupComplete && !meta?.setupInProgress) {
        return
      }
      if (!meta?.setupInProgress) {
        return
      }
      let elapsedSec = Math.floor((Date.now() - started) / 1000)
      let detail = elapsedSec >= 120
          ? 'Still waiting… you can Retry or Reset database.'
          : elapsedSec + 's elapsed'
      await report({
        phase: 'peer-setup',
        label: 'Waiting for another tab to finish setup…',
        detail,
        indeterminate: true,
      })
      if (Date.now() - started > 120000) {
        throw new Error('Timed out waiting for setup to complete')
      }
      await Utilities.sleep(500)
    }
  }

  /**
   * @param {string} storeName
   * @return {Promise<*|null>}
   * @private
   */
  async _readLegacyStoreForBackup(storeName)
  {
    await this._repos.storage.open()
    let db = this._repos.storage._db
    if (!db?.objectStoreNames.contains(storeName)) {
      return null
    }
    if (storeName === 'tagRuleSets') {
      return this._repos.storage.get(storeName, 'tagRuleSets')
    }
    return this._repos.storage.getAll(storeName)
  }

  /**
   * @param {BrazenZipWriter} zip
   * @param {string} filename
   * @return {void}
   * @private
   */
  _triggerBackupZipDownload(zip, filename)
  {
    let link = document.createElement('a')
    link.download = filename
    let objectUrl = URL.createObjectURL(zip.build())
    link.href = objectUrl
    link.click()
    setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
  }

  /**
   * Shared zip assembly for user backup and pre-migration safety export.
   * @param {{ purpose: 'preMigrationSafety'|'userBackup', stores?: string[], onProgress?: function(MigrationProgress|string): void|Promise<void>|null }} options
   * @return {Promise<{zip: BrazenZipWriter, filename: string, meta: object|null}>}
   * @private
   */
  async _buildBackupZip({purpose, stores = null, onProgress = null})
  {
    await this._repos.storage.open()
    let meta = await this._repos.meta.get()
    let zip = new BrazenZipWriter()

    if (purpose === 'preMigrationSafety') {
      let manifestStores = [...(stores ?? [
        'meta', 'settings', 'apis', 'tagTypes', 'tags', 'bookmarks', 'ledgerEntries',
      ])]
      let tagRules = await this._readLegacyStoreForBackup('tagRules')
      if (tagRules != null) {
        manifestStores.push('tagRules')
      }
      let tagRuleSets = await this._readLegacyStoreForBackup('tagRuleSets')
      if (tagRuleSets != null) {
        manifestStores.push('tagRuleSets')
      }
      let tagPartCount = await addStoreJsonPartsToZip(
          zip, this._repos.storage, IDB_STORE_TAGS, BACKUP_JSON_PART_SIZE, onProgress,
          {phase: 'safety-backup', label: 'Creating safety backup…'},
      )
      let bookmarkPartCount = await addStoreJsonPartsToZip(
          zip, this._repos.storage, IDB_STORE_BOOKMARKS, BACKUP_JSON_PART_SIZE, onProgress,
          {phase: 'safety-backup', label: 'Creating safety backup…'},
      )
      let ledgerPartCount = await addStoreJsonPartsToZip(
          zip, this._repos.storage, IDB_STORE_LEDGER, BACKUP_JSON_PART_SIZE, onProgress,
          {phase: 'safety-backup', label: 'Creating safety backup…'},
      )
      zip.addFile('manifest.json', JSON.stringify({
        version: PRE_MIGRATION_BACKUP_VERSION,
        purpose: 'preMigrationSafety',
        scriptPrefix: this._scriptPrefix,
        dbName: this._repos.storage.dbName,
        revisionId: meta?.revisionId,
        exportedAt: Date.now(),
        stores: manifestStores,
        chunkedStores: {
          tags: tagPartCount,
          bookmarks: bookmarkPartCount,
          ledgerEntries: ledgerPartCount,
        },
      }, null, 2))
      zip.addFile('meta.json', JSON.stringify(meta, null, 2))
      zip.addFile('settings.json', JSON.stringify(await this._repos.settings.getDocument(), null, 2))
      zip.addFile('apis.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_APIS, 'apis'), null, 2))
      zip.addFile('tagTypes.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_TAG_TYPES, 'tagTypes'), null, 2))
      if (tagRules != null) {
        zip.addFile('tagRules.json', JSON.stringify(tagRules, null, 2))
      }
      if (tagRuleSets != null) {
        zip.addFile('tagRuleSets.json', JSON.stringify(tagRuleSets, null, 2))
      }
      return {
        zip,
        filename: this._scriptPrefix + 'pre-migration-backup.zip',
        meta,
        safetyBackupCounts: {
          tags: tagPartCount,
          bookmarks: bookmarkPartCount,
          ledgerEntries: ledgerPartCount,
        },
      }
    }

    let manifestStores = stores ?? IDB_ALL_STORES
    zip.addFile('manifest.json', JSON.stringify({
      version: CONFIG_BACKUP_VERSION,
      scriptPrefix: this._scriptPrefix,
      dbName: this._repos.storage.dbName,
      revisionId: meta?.revisionId,
      exportedAt: Date.now(),
      stores: manifestStores,
    }, null, 2))
    zip.addFile('meta.json', JSON.stringify(meta, null, 2))
    zip.addFile('settings.json', JSON.stringify(await this._repos.settings.getDocument(), null, 2))
    zip.addFile('apis.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_APIS, 'apis'), null, 2))
    zip.addFile('tagTypes.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_TAG_TYPES, 'tagTypes'), null, 2))
    zip.addFile('tags.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_TAGS), null, 2))
    zip.addFile('rulesetFields.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_RULESET_FIELDS), null, 2))
    zip.addFile('rulesetEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_RULESET_ENTRIES), null, 2))
    zip.addFile('bookmarks.json', JSON.stringify(await this._exportBookmarksBackupRows(), null, 2))
    zip.addFile('ledgerEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_LEDGER), null, 2))
    return {
      zip,
      filename: this._scriptPrefix + 'backup.zip',
      meta,
    }
  }

  /**
   * Auto-download a legacy-compatible safety backup before destructive migration.
   * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
   * @return {Promise<string>} Download filename
   */
  async createPreMigrationSafetyBackup(onProgress = null)
  {
    let {zip, filename, meta, safetyBackupCounts} = await this._buildBackupZip({
      purpose: 'preMigrationSafety',
      onProgress,
    })
    this._triggerBackupZipDownload(zip, filename)
    meta = meta ?? await this._repos.storage.createDefaultMeta()
    meta.migrationSafetyBackupAt = Date.now()
    meta.migrationSafetyBackupRevisionId = meta.revisionId ?? null
    meta.migrationSafetyBackupCounts = safetyBackupCounts
    await this._repos.meta.put(meta)
    try {
      sessionStorage.setItem(this._scriptPrefix + 'pre-migration-backup-file', filename)
    } catch (error) {
    }
    return filename
  }

  /**
   * @return {string|null}
   */
  getPreMigrationBackupFilenameHint()
  {
    try {
      return sessionStorage.getItem(this._scriptPrefix + 'pre-migration-backup-file')
    } catch (error) {
      return null
    }
  }

  /**
   * @type {((event: PageTransitionEvent) => void)|null}
   * @private
   */
  _idbPageshowHandler = null

  /**
   * @type {Function|null}
   * @private
   */
  _onDockToggle = null

  /**
   * @type {string|null}
   * @private
   */
  _legacyScriptPrefix = null

  /**
   * @type {Function|null}
   * @private
   */
  _onConfigurationChange = null

  /**
   * @param {string} scriptPrefix
   * @param {BrazenViewLayer} uiGenerator
   * @param {SearchEnhancerTagSelectorGeneratorCallback} tagSelectorGenerator
   */
  constructor(scriptPrefix, uiGenerator, tagSelectorGenerator)
  {
    this._scriptPrefix = scriptPrefix
    this._tagSelectorGenerator = tagSelectorGenerator
    this._uiGen = uiGenerator
    this._repos = new BrazenStorageRepositories(scriptPrefix, (source) => {
      if (globalThis.__brazenRuntimePipelineConfigSources.has(source)) {
        this._runtimePipelineChangeDelegate?.(source)
        return
      }
      this.notifyConfigurationChange(source, true)
    }, (revisionId, options = {}) => {
      this._applyRevisionBumpSignals(revisionId, options)
      // Ledger-only bumps share meta.revisionId but are not config loads — do not advance
      // _syncedRevisionId or visibilitychange would no-op while tag-blacklist RAM stays stale.
      // Config-domain bumps (tag/ruleset/settings) advance the cursor so same-tab refocus after
      // our own write does not wipe unsaved panel edits.
      if (options?.source !== 'ledger') {
        this._syncedRevisionId = revisionId
      }
      if (options?.domainConfigSeq != null) {
        this._syncedDomainConfigSeq = options.domainConfigSeq
        this._syncedDomainTagsSeq = options.domainTagsSeq ?? 0
        this._syncedDomainLedgerSeq = options.domainLedgerSeq ?? 0
      }
    })
    this._repos.bindRevisionSignals({
      getConfigRevision: () => this._getConfigRevisionStamp(),
      getTagsRevision: () => this._getTagsRevisionStamp(),
      getLedgerRevision: () => this._getLedgerRevisionStamp(),
    })
    this._repos.bindConfigHandlers({
      persistMountedSettings: () => this.persistMountedSettingsCoordinator(),
    })
    this._scriptSetupHandler = null
    this._storageReady = false
    this._ledgerImportRevisionPending = false
    this._syncedRevisionId = null
    this._syncedDomainConfigSeq = 0
    this._syncedDomainTagsSeq = 0
    this._syncedDomainLedgerSeq = 0
    /** @type {number} domainConfigSeq after last boot ruleset hydrate / compile pass */
    this._bootRulesetCompiledConfigSeq = -1
    /** @type {boolean} Full ruleset/ledger hydrate completed this boot (setup path sets true inline). */
    this._bootFieldsHydrated = false
    /** @type {Map<string, *>} */
    this._fieldSeeds = new Map()
    /** @type {Function|null} Debounced visibility wake from Framework (foreign sync coalescing). */
    this._visibilityWakeDelegate = null
    /** @type {Promise<void>|null} In-flight foreign revision sync. */
    this._foreignSyncInFlight = null
    /** @type {number} Last domain ledger seq applied to revision signal atoms (dedupe). */
    this._lastSignalLedgerSeq = -1
    /** @type {number} Last domain config seq applied to revision signal atoms (dedupe). */
    this._lastSignalConfigSeq = -1
    /** @type {number} Last domain tags seq paired with the last config signal bump (dedupe). */
    this._lastSignalTagsSeq = -1
  }

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

  /**
   * @param {ConfigurationField} field
   * @return {*|undefined}
   * @private
   */
  _deriveStructuralDefault(field)
  {
    if (field.type === CONFIG_TYPE_SELECT || field.type === CONFIG_TYPE_RADIOS_GROUP) {
      return field.options?.[0]?.[1] ?? null
    }
    if (field.type === CONFIG_TYPE_NUMBER) {
      return field.minimum ?? null
    }
    if (field.type === CONFIG_TYPE_RANGE) {
      return field.minimum == null ? null : {minimum: field.minimum, maximum: field.minimum}
    }
    return undefined
  }

  /**
   * Normalize IDB / legacy range payloads to `{minimum, maximum}`.
   * Returns null when nothing usable is stored — never invent defaults.
   * @param {*} stored
   * @return {{minimum: number, maximum: number}|null}
   * @private
   */
  _coerceStoredRangeValue(stored)
  {
    if (stored == null) {
      return null
    }
    if (Array.isArray(stored)) {
      if (stored.length < 2) {
        return null
      }
      return {
        minimum: Number.parseInt(stored[0], 10) || 0,
        maximum: Number.parseInt(stored[1], 10) || 0,
      }
    }
    if (typeof stored !== 'object') {
      return null
    }
    let minimum = stored.minimum ?? stored.min
    let maximum = stored.maximum ?? stored.max
    if (minimum === undefined && maximum === undefined) {
      return null
    }
    return {
      minimum: Number.parseInt(minimum, 10) || 0,
      maximum: Number.parseInt(maximum, 10) || 0,
    }
  }

  /**
   * @param {string} fieldKey
   * @return {*|undefined}
   * @private
   */
  _resolveSeedDefault(fieldKey)
  {
    let field = this._config[fieldKey]
    let seed = this._fieldSeeds.get(fieldKey)
    if (seed !== undefined) {
      return (seed && typeof seed === 'object' && 'default' in seed) ? seed.default : seed
    }
    let structural = field ? this._deriveStructuralDefault(field) : undefined
    if (structural !== undefined) {
      return structural
    }
    return field ? CONFIG_TYPE_DEFAULTS[field.type] : undefined
  }

  /**
   * @param {string} key
   * @return {void}
   * @private
   */
  _reresolveField(key)
  {
    // Seeds feed _seedAllFields / migration only — never push defaults into field.value at registration.
  }

  /**
   * @param {string} key
   * @param {*} spec
   * @return {BrazenConfigurationManager}
   */
  registerFieldSeed(key, spec)
  {
    this._fieldSeeds.set(key, spec)
    let field = this._config[key]
    if (field?.type === CONFIG_TYPE_RULESET) {
      if (!field.templateId && spec?.templateId) {
        field.templateId = spec.templateId
      }
      this._syncRulesetSubjectFromTemplate(field)
      field.widget?.updatePanelCopy?.(this._resolveRulesetPanelCopy(field))
    }
    return this
  }

  /**
   * @param {object} map
   * @return {BrazenConfigurationManager}
   */
  registerFieldSeeds(map)
  {
    for (let [key, spec] of Object.entries(map ?? {})) {
      this.registerFieldSeed(key, spec)
    }
    return this
  }

  /**
   * @return {Map<string, *>}
   */
  getFieldSeeds()
  {
    return this._fieldSeeds
  }

  /**
   * @param {ConfigurationField} field
   * @return {object}
   * @private
   */
  _sanitizeRulesetUserConfig(config = {})
  {
    let out = {}
    for (let key of RULESET_USER_CONFIG_KEYS) {
      if (config[key] !== undefined) {
        out[key] = config[key]
      }
    }
    return out
  }

  /**
   * Merge stored ruleset user config into the field object without replacing the reference
   * (ruleset panel widgets hold the same `field.config` object).
   * @param {ConfigurationField} field
   * @param {object|null|undefined} storedConfig
   * @return {void}
   * @private
   */
  _applyRulesetUserConfig(field, storedConfig)
  {
    if (!field.config || typeof field.config !== 'object') {
      field.config = {}
    }
    let sanitized = this._sanitizeRulesetUserConfig(storedConfig ?? {})
    for (let key of Object.keys(field.config)) {
      if (!(key in sanitized)) {
        delete field.config[key]
      }
    }
    Object.assign(field.config, sanitized)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _seedAllFields()
  {
    for (let field of Object.values(this._config)) {
      if (field.persist === false && field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      if (field.type === CONFIG_TYPE_RULESET) {
        let spec = this._fieldSeeds.get(field.key)
        if (!spec || !spec.templateId) {
          continue
        }
        let existing = await this._repos.rulesetFields.get(field.key)
        let serializedTemplateConfig = this._serializeRulesetTemplateConfig(spec.templateConfig ?? {})
        if (!existing) {
          await this._repos.rulesetFields.upsert({
            fieldKey: field.key,
            templateId: spec.templateId,
            templateConfig: serializedTemplateConfig,
            config: {...DEFAULT_RULESET_USER_CONFIG, ...(spec.config ?? {})},
          })
        } else {
          let templateIdChanged = existing.templateId !== spec.templateId
          let templateConfigChanged = JSON.stringify(existing.templateConfig ?? {}) !==
              JSON.stringify(serializedTemplateConfig)
          if (templateIdChanged || templateConfigChanged) {
            await this._repos.rulesetFields.upsert({
              ...existing,
              templateId: spec.templateId,
              templateConfig: serializedTemplateConfig,
            })
          }
        }
        continue
      }
      let current = await this._repos.settings.getField(field.key)
      if (current === undefined) {
        await this._repos.settings.putField(field.key, this._resolveSeedDefault(field.key))
      }
    }
  }

  /**
   * @param {string} type
   * @param {string} name
   * @param {*} value
   * @param {string|null} help
   * @return ConfigurationField
   * @private
   */
  _createField(type, name, value, help)
  {
    let fieldKey = this._formatFieldKey(name)
    let field = this._config[fieldKey]
    if (field) {
      field.key = field.key ?? fieldKey
      if (help) {
        field.help = help
      }
    } else {
      field = {
        key: fieldKey,
        element: null,
        help: help,
        title: name,
        type: type,
        value: undefined,
        createElement: null,
        setFromUserInterface: null,
        updateUserInterface: null,
      }
      this._config[fieldKey] = field
    }
    this._lastFieldKey = fieldKey
    this._wireDefaultFieldHooks(field)
    this._wireFieldBuilder(field)
    return field
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _wireFieldBuilder(field)
  {
    field.setTitle = (title) => {
      field.title = title
      return field
    }
    field.setHelp = (help) => {
      field.help = help
      return field
    }
    field.setRows = (rows) => {
      field.rows = rows
      return field
    }
    field.setMinimum = (minimum) => {
      field.minimum = minimum
      return field
    }
    field.setMaximum = (maximum) => {
      field.maximum = maximum
      return field
    }
    field.setOptions = (keyValuePairs) => {
      field.options = keyValuePairs
      return field
    }
    field.setTranslateFromUI = (callback) => {
      field.onTranslateFromUI = callback
      return field
    }
    field.setFormatForUI = (callback) => {
      field.onFormatForUI = callback
      return field
    }
    field.setOptimize = (callback) => {
      field.onOptimize = callback
      return field
    }
    field.setSortRules = (sortRules = true) => {
      field.sortRules = sortRules
      return field
    }
    field.setReadOnly = (readOnly = true) => {
      field.readOnly = !!readOnly
      return field
    }
    field.setSubstitutionComposer = (options = {}) => {
      field.substitutionComposer = typeof options === 'function'
          ? {normalize: options}
          : {...options}
      field.readOnly = true
      return field
    }
    field.setFormatter = (formatter) => {
      field.formatter = formatter
      return field
    }
    field.setPrimaryField = (primaryField) => {
      field.ledgerPrimaryField = primaryField
      return field
    }
    field.setIsValidId = (isValidId) => {
      field.ledgerIsValidId = isValidId
      return field
    }
    field.setShowAddButton = (showAddButton) => {
      field.showAddButton = showAddButton
      return field
    }
    field.setSortHelpText = (sortHelpText) => {
      field.sortHelpText = sortHelpText
      return field
    }
    field.setRulesetSubject = (rulesetSubject) => {
      field.rulesetSubject = rulesetSubject
      field._rulesetSubjectExplicit = true
      return field
    }
    field.setPageMatch = (pageMatch) => {
      field.pageMatch = pageMatch
      return field
    }
    field.setOnAdd = (onAdd) => {
      field.onAdd = onAdd
      return field
    }
    field.setOnSort = (onSort) => {
      field.onSort = onSort
      return field
    }
    field.setOnRemove = (onRemove) => {
      field.onRemove = onRemove
      return field
    }
    field.setOnNavigate = (onNavigate) => {
      field.onNavigate = onNavigate
      return field
    }
    field.setGetRowActions = (getRowActions) => {
      field.getRowActions = getRowActions
      return field
    }
    field.setOnBeforeRender = (onBeforeRender) => {
      field.onBeforeRender = onBeforeRender
      return field
    }
    field.setAttributeActionsConfig = (config) => {
      field.attributeActionsConfig = config ?? null
      field.rebuildRulesetTagIndex?.()
      return field
    }
    field.setDockButton = (dockOptions) => {
      field.dock = {...(field.dock ?? {}), ...dockOptions}
      return field
    }
    /**
     * Apply a named dock-button recipe, then merge optional overrides into `field.dock`.
     * Framework / Download Manager ship templates on registration; apps only order keys
     * (and fully define custom action docks).
     *
     * @param {string} name
     * @param {object} [args]
     * @return {ConfigurationField}
     */
    field.applyDockTemplate = (name, args = {}) => {
      this._applyDockTemplate(field, name, args)
      return field
    }
    field.setDockSlideOut = (childNames) => {
      field.dockSlideOutChildren = childNames
      for (let childName of childNames) {
        let childField = this.getField(childName)
        if (childField) {
          childField.dock = {...(childField.dock ?? {}), parent: field.key}
        }
      }
      return field
    }
    field.setDockSlideOutNodes = (getNodes) => {
      field.dock = field.dock ?? {}
      field.dock.getSlideOutNodes = getNodes
      return field
    }
    field.setOnClick = (onClick) => {
      field.onClick = onClick
      return field
    }
    field.setAction = (onClick) => {
      field.onClick = onClick
      return field
    }
    field.setDetailFormTemplate = (factory) => {
      field.detailFormTemplate = factory
      return field
    }
    field.openDetailForm = (mode, ctx = {}) => {
      if (typeof field.detailFormTemplate === 'function' && mode !== 'help') {
        let content = field.detailFormTemplate(mode, {
          ...ctx,
          field,
          fieldKey: field.key,
          help: field.help,
          repos: this._repos,
        })
        let title = ctx.title
        if (title == null && field.type === CONFIG_TYPE_RULESET) {
          title = this._resolveRulesetDetailTitle(field, mode)
        } else if (title == null) {
          title = mode === 'create' ? 'Add rule' : 'Edit rule'
        }
        BrazenViewLayer.openSettingsDetailPane({
          title,
          content,
          onClose: ctx.onClose ?? null,
        })
        return field
      }
      if (field.help != null && field.help !== '') {
        let content = typeof field.help === 'string' ?
            Utilities.makeEl('div', {class: 'bv-field-help-detail', html: field.help}) :
            field.help
        BrazenViewLayer.openSettingsDetailPane({
          title: ctx.title ?? field.title ?? '',
          content,
          onClose: ctx.onClose ?? null,
        })
      }
      return field
    }
    field.setTemplate = (templateId) => {
      field.templateId = templateId
      this._syncRulesetSubjectFromTemplate(field)
      field.widget?.updatePanelCopy?.(this._resolveRulesetPanelCopy(field))
      return field
    }
    field.setTemplateConfig = (config) => {
      field.templateConfig = {...(field.templateConfig ?? {}), ...(config ?? {})}
      return field
    }
    field.setConfigOptionHelp = (partial) => {
      field.configOptionHelp = {...(field.configOptionHelp ?? {}), ...(partial ?? {})}
      return field
    }
    field.setGroupingAvailable = (available) => {
      field.capabilities = {...(field.capabilities ?? {}), grouping: !!available}
      return field
    }
    field.setSortModes = (modes) => {
      field.sortModes = modes
      return field
    }
    field.setCrudOptions = (options) => {
      field.crudOptions = {...(field.crudOptions ?? {}), ...(options ?? {})}
      return field
    }
    field.setAlwaysRefresh = (alwaysRefresh = true) => {
      field.alwaysRefresh = !!alwaysRefresh
      return field
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _wireDefaultFieldHooks(field)
  {
    if (!field.serializeForBackup) {
      field.serializeForBackup = () => field.value
    }
    if (!field.applyFromBackup) {
      field.applyFromBackup = (blob) => {
        field.value = blob
        if (field.type === CONFIG_TYPE_RULESET && field.getOptimized) {
          void field.getOptimized()
          return
        }
        if (field.type === CONFIG_TYPE_RULESET) {
          field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
        }
      }
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  _ensureConfigSignals()
  {
    if (this._configRevisionAtom) {
      return true
    }
    let createAtom = globalThis.createAtom
    if (typeof createAtom !== 'function') {
      return false
    }
    this._configRevisionAtom = createAtom(0, {path: 'config.revision'})
    this._ledgerRevisionAtom = createAtom(0, {path: 'config.ledgerRevision'})
    this._tagsRevisionAtom = createAtom(0, {path: 'tags.revision'})
    return true
  }

  /**
   * @return {number}
   * @private
   */
  _getConfigRevisionStamp()
  {
    return this._configRevisionAtom?.value ?? 0
  }

  /**
   * @return {number}
   * @private
   */
  _getTagsRevisionStamp()
  {
    return this._tagsRevisionAtom?.value ?? 0
  }

  /**
   * @return {number}
   * @private
   */
  _getLedgerRevisionStamp()
  {
    return this._ledgerRevisionAtom?.value ?? 0
  }

  /**
   * @param {number|string} revisionId
   * @param {{source?: string}} [options]
   * @private
   */
  _applyRevisionBumpSignals(revisionId, options = {})
  {
    void revisionId
    if (!this._ensureConfigSignals()) {
      return
    }
    if (options?.source === 'ledger') {
      let ledgerSeq = options?.domainLedgerSeq
      if (typeof ledgerSeq === 'number' && ledgerSeq === this._lastSignalLedgerSeq) {
        return
      }
      if (typeof ledgerSeq === 'number') {
        this._lastSignalLedgerSeq = ledgerSeq
      }
      this._ledgerRevisionAtom.set(this._ledgerRevisionAtom.value + 1)
      globalThis.__brazenReactor?.mark?.('framework', 'revision-bump', {
        key: 'ledger',
        data: {activeCause: globalThis.__brazenReactor?.activeCauseLabel?.()},
      })
      return
    }
    let configSeq = options?.domainConfigSeq
    let tagsSeq = options?.domainTagsSeq
    let tagsTouched = options?.tagsTouched === true
    let configAdvanced = typeof configSeq === 'number' && configSeq !== this._lastSignalConfigSeq
    let tagsAdvanced = tagsTouched && typeof tagsSeq === 'number' && tagsSeq !== this._lastSignalTagsSeq
    if (!configAdvanced && !tagsAdvanced) {
      return
    }
    if (configAdvanced) {
      this._lastSignalConfigSeq = configSeq
      this._configRevisionAtom.set(this._configRevisionAtom.value + 1)
      this._repos.rulesetFields.invalidateCompiledCache()
      this._restampAllSettingCache()
    }
    if (tagsAdvanced) {
      this._lastSignalTagsSeq = tagsSeq
      this._tagsRevisionAtom.set(this._tagsRevisionAtom.value + 1)
    }
    let bumpKey = configAdvanced && tagsAdvanced ? 'config+tags'
        : tagsAdvanced ? 'tags'
            : 'config'
    globalThis.__brazenReactor?.mark?.('framework', 'revision-bump', {
      key: bumpKey,
      data: {activeCause: globalThis.__brazenReactor?.activeCauseLabel?.()},
    })
  }

  /**
   * Local config revision bumps keep existing `_settingCache` rows valid — only the stamp advances.
   * @private
   */
  _restampAllSettingCache()
  {
    if (!this._settingCache.size) {
      this._settingCacheWriteKeys.clear()
      return
    }
    let rev = this._getConfigRevisionStamp()
    for (let cached of this._settingCache.values()) {
      cached.rev = rev
    }
    this._settingCacheWriteKeys.clear()
  }

  /**
   * Re-stamp settings cached in the same write transaction as a config revision bump.
   * @private
   */
  _restampPendingSettingCache()
  {
    this._restampAllSettingCache()
  }

  /**
   * @private
   */
  _invalidateLedgerPositiveCaches()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field?.type === CONFIG_TYPE_LEDGER && typeof field.reload === 'function') {
        void field.reload()
      }
    }
  }

  /**
   * @param {(command: object) => Promise<void>} sender
   */
  setCommandSender(sender)
  {
    this._commandSender = typeof sender === 'function' ? sender : null
  }

  /**
   * Per-tab config->UI reaction (local-scope Reactor lane).
   * @param {(event: {source?: string, local?: boolean, detail?: *}) => void} delegate
   */
  setConfigUiReactionDelegate(delegate)
  {
    this._configUiReactionDelegate = typeof delegate === 'function' ? delegate : null
  }

  /**
   * Read-only DM UI refresh on runtime pipeline store writes (no config-ui / no cross-tab fan-out).
   * @param {(source: string) => void} delegate
   */
  setRuntimePipelineChangeDelegate(delegate)
  {
    this._runtimePipelineChangeDelegate = typeof delegate === 'function' ? delegate : null
  }

  /**
   * Coordinator-only persist for mounted panel fields (kernel write-through / save Command).
   * @return {Promise<BrazenConfigurationManager>}
   */
  async persistMountedSettingsCoordinator()
  {
    await this._ensureStorageReady()
    this.update()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.persist === false || !field.element) {
        continue
      }
      field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value], field.value)
      await this._repos.settings.putField(fieldKey, field.value, field.optimized)
      this._cacheSetting(fieldKey, field.value, field.optimized, true)
    }
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET && field.getOptimized) {
        await field.getOptimized()
      }
    }
    await this._repos.meta.bumpRevision()
    return this
  }

  /**
   * Shared Reactor EventBus (Configuration Manager creates; Download Manager reuses).
   * @return {object|null}
   */
  getConfigBus()
  {
    this._ensureConfigReactor()
    return this._configBus
  }

  /**
   * Wire config-domain BroadcastChannel bridge (idempotent).
   * @return {boolean}
   * @private
   */
  _ensureConfigReactor()
  {
    if (this._configReactorReady) {
      return true
    }
    let EventBus = globalThis.BrazenEventBus
    if (typeof EventBus?.create !== 'function') {
      return false
    }
    this._ensureConfigSignals()
    this._configBus = EventBus.create(this._scriptPrefix)
    if (this._configBusUnsubscribe) {
      this._configBusUnsubscribe()
    }
    this._configBusUnsubscribe = this._configBus.subscribe((message) => {
      this._handleConfigReactorMessage(message)
    })
    this._configReactorReady = true
    return true
  }

  /**
   * @param {object|null|undefined} message
   * @private
   */
  _handleConfigReactorMessage(message)
  {
    if (!message || message.tabId === this._configBus?.tabId) {
      return
    }
    if (message.kind !== 'command') {
      return
    }
    let command = message.command
    if (command?.type !== 'custom') {
      return
    }
    let name = command.payload?.name
    let data = command.payload?.data
    if (name === REACTOR_CONFIG_CHANGE) {
      void this.syncFromForeignRevisionIfNeeded().then(() => {
        if (data?.source === 'ledger') {
          return
        }
        if (typeof this._configUiReactionDelegate === 'function') {
          this._configUiReactionDelegate({
            source: data?.source ?? 'all',
            local: false,
            detail: data?.detail ?? null,
          })
        }
      })
      return
    }
    if (name === REACTOR_RULESET_MUTATION) {
      void this._handleRulesetMutation(data ?? {})
    }
  }

  /**
   * @param {{source?: string, local?: boolean, detail?: *}} data
   * @private
   */
  _publishConfigChange(data)
  {
    if (!this._ensureConfigReactor() || !this._configBus) {
      return
    }
    this._configBus.publish({
      kind: 'command',
      tabId: this._configBus.tabId,
      command: {
        type: 'custom',
        payload: {
          name: REACTOR_CONFIG_CHANGE,
          data,
        },
      },
    })
  }

  /**
   * @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} detail
   * @private
   */
  _publishRulesetMutation(detail)
  {
    if (!this._ensureConfigReactor() || !this._configBus) {
      return
    }
    this._configBus.publish({
      kind: 'command',
      tabId: this._configBus.tabId,
      command: {
        type: 'custom',
        payload: {
          name: REACTOR_RULESET_MUTATION,
          data: detail,
        },
      },
    })
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @param {*} optimized
   * @private
   */
  _cacheSetting(fieldKey, value, optimized, trackPending = false)
  {
    this._settingCache.set(fieldKey, {
      value,
      optimized,
      rev: this._getConfigRevisionStamp(),
    })
    if (trackPending) {
      this._settingCacheWriteKeys.add(fieldKey)
    }
  }

  /**
   * @param {string} fieldKey
   * @return {{value: *, optimized: *}|undefined}
   * @private
   */
  _getCachedSetting(fieldKey)
  {
    let cached = this._settingCache.get(fieldKey)
    if (!cached) {
      return undefined
    }
    if (cached.rev !== this._getConfigRevisionStamp()) {
      this._settingCache.delete(fieldKey)
      return undefined
    }
    return cached
  }

  /**
   * @param {string|string[]|null|undefined} tags
   * @param {string|string[]|null|undefined} fieldKeys
   * @return {{tags: string[], fieldKeys: string[]}|null}
   */
  createTagsChangeDetail(tags = null, fieldKeys = null)
  {
    let tagList = []
    let seenTags = new Set()
    for (let tag of Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])) {
      let name = String(tag ?? '').trim()
      if (!name || seenTags.has(name)) {
        continue
      }
      seenTags.add(name)
      tagList.push(name)
    }
    let keyList = []
    let seenKeys = new Set()
    for (let key of Array.isArray(fieldKeys) ? fieldKeys : (fieldKeys != null && fieldKeys !== '' ? [fieldKeys] : [])) {
      let fieldKey = String(key ?? '').trim()
      if (!fieldKey || seenKeys.has(fieldKey)) {
        continue
      }
      seenKeys.add(fieldKey)
      keyList.push(fieldKey)
    }
    if (!tagList.length && !keyList.length) {
      return null
    }
    return {tags: tagList, fieldKeys: keyList}
  }

  /**
   * @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} left
   * @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} right
   * @return {{tags: string[], fieldKeys: string[]}|null}
   */
  mergeChangeDetails(left, right)
  {
    let skip = Boolean(left?.skipMountedRefresh || right?.skipMountedRefresh)
    if (!left) {
      if (!right) {
        return skip ? {tags: [], fieldKeys: [], skipMountedRefresh: true} : null
      }
      let detail = this.createTagsChangeDetail(right.tags, right.fieldKeys)
      if (!detail && skip) {
        return {tags: [], fieldKeys: [], skipMountedRefresh: true}
      }
      if (detail && skip) {
        detail.skipMountedRefresh = true
      }
      return detail
    }
    if (!right) {
      let detail = this.createTagsChangeDetail(left.tags, left.fieldKeys)
      if (!detail && skip) {
        return {tags: [], fieldKeys: [], skipMountedRefresh: true}
      }
      if (detail && skip) {
        detail.skipMountedRefresh = true
      }
      return detail
    }
    let merged = this.createTagsChangeDetail(
        [...(left.tags ?? []), ...(right.tags ?? [])],
        [...(left.fieldKeys ?? []), ...(right.fieldKeys ?? [])],
    )
    if (!merged && skip) {
      return {tags: [], fieldKeys: [], skipMountedRefresh: true}
    }
    if (merged && skip) {
      merged.skipMountedRefresh = true
    }
    return merged
  }

  /**
   * @param {string} source
   * @param {boolean} local
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @return {BrazenConfigurationManager}
   */
  /**
   * Monotonic config-domain revision stamp (Reactor `config.revision` atom).
   * @return {number}
   */
  getConfigRevisionStamp()
  {
    return this._getConfigRevisionStamp()
  }

  /**
   * Monotonic tags revision stamp (Reactor `tags.revision` atom).
   * @return {number}
   */
  getTagsRevisionStamp()
  {
    return this._getTagsRevisionStamp()
  }

  /**
   * Monotonic ledger revision stamp (Reactor `config.ledgerRevision` atom).
   * @return {number}
   */
  getLedgerRevisionStamp()
  {
    return this._getLedgerRevisionStamp()
  }

  /**
   * Revision atoms for signal effects (`createEffect` dependency reads).
   * @return {{config: object, tags: object, ledger: object}|null}
   */
  getRevisionAtoms()
  {
    if (!this._ensureConfigSignals()) {
      return null
    }
    return {
      config: this._configRevisionAtom,
      tags: this._tagsRevisionAtom,
      ledger: this._ledgerRevisionAtom,
    }
  }

  notifyConfigurationChange(source = 'all', local = true, detail = null)
  {
    let profiler = globalThis.__brazenReactor
    let reentrant = profiler?.noteReentrant?.('config-change') ?? false
    profiler?.mark?.('framework', 'notifyConfigurationChange', {
      key: source,
      data: {
        local,
        activeCause: profiler?.activeCauseLabel?.(),
        reentrant,
      },
    })
    Utilities.callEventHandler(this._onConfigurationChange, [{
      manager: this,
      source,
      local,
      detail: detail ?? null,
    }])
    if (local) {
      this._publishConfigChange({source, local: true, detail: detail ?? null})
    }
    return this
  }

  /**
   * Local ruleset row mutation + optional cross-tab fan-out via Reactor bus.
   * @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} [detail]
   * @return {BrazenConfigurationManager}
   */
  notifyRulesetMutation(detail = {})
  {
    void this._handleRulesetMutation(detail)
    this._publishRulesetMutation(detail)
    return this
  }

  /**
   * @param {string} source
   * @param {boolean} local
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @return {void}
   * @private
   */
  _notifyConfigurationChange(source = 'all', local = true, detail = null)
  {
    this.notifyConfigurationChange(source, local, detail)
  }

  /**
   * @param {ConfigurationField} field
   * @return {ConfigurationField}
   * @private
   */
  _overlayFieldFromCache(field)
  {
    if (!field || !this._storageReady) {
      return field
    }
    let cached = this._getCachedSetting(field.key)
    if (cached) {
      if (field.type === CONFIG_TYPE_RANGE) {
        let range = this._coerceStoredRangeValue(cached.value)
        if (range) {
          field.value = range
        }
      } else {
        field.value = cached.value
      }
      field.optimized = cached.optimized
    }
    return field
  }

  /**
   * @param {ConfigurationField} field
   * @return {*}
   * @private
   */
  _getEffectiveFieldValue(field)
  {
    if (this._storageReady) {
      let cached = this._getCachedSetting(field.key)
      if (cached) {
        if (field.type === CONFIG_TYPE_RANGE) {
          return this._coerceStoredRangeValue(cached.value) ?? field.value
        }
        return cached.value
      }
    }
    if (field.type === CONFIG_TYPE_RANGE) {
      return this._coerceStoredRangeValue(field.value) ?? field.value
    }
    return field.value
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _refreshSettingCacheFromIdb()
  {
    if (!this._storageReady) {
      return
    }
    this._ensureConfigSignals()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET) {
        let compiled = await this._compileRulesetFieldFromIdb(fieldKey)
        this._cacheSetting(fieldKey, compiled?.rawLines ?? [], compiled?.optimized ?? null)
        continue
      }
      if (field.persist === false) {
        continue
      }
      if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
          field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
          field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
        if (field.type === CONFIG_TYPE_RULESET) {
          continue
        }
        let stored = await this._repos.settings.getField(fieldKey)
        if (stored) {
          this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
        }
      }
    }
  }

  /**
   * Scalar settings only — dock flags / orientation without ruleset compile (boot paint path).
   * @return {Promise<void>}
   * @private
   */
  async _refreshScalarSettingCacheFromIdb()
  {
    if (!this._storageReady) {
      return
    }
    this._ensureConfigSignals()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_LEDGER) {
        continue
      }
      if (field.persist === false) {
        continue
      }
      if (field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
          field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
          field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
        let stored = await this._repos.settings.getField(fieldKey)
        if (stored) {
          this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
        }
      }
    }
  }

  /**
   * @param {string} name
   * @return {string}
   * @private
   */
  _formatFieldKey(name)
  {
    return Utilities.toKebabCase(name)
  }

  /**
   * @param {ConfigurationField} field
   * @return {{help: *}}
   * @private
   */
  _fieldHelpLabelOptions(field)
  {
    return {
      help: field.help ?? '',
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _warnIfMissingHelp(field)
  {
    if (field.help != null && field.help !== '') {
      return
    }
    console.warn('[BrazenCM] Missing help for field:', field.key)
  }

  /**
   * @param {string[]} rules
   * @return {string[]}
   * @private
   */
  _deduplicateRulesetRules(rules)
  {
    if (!Array.isArray(rules)) {
      return rules
    }
    if (!rules.length) {
      return rules
    }

    let seen = new Set()
    let deduplicated = []
    for (let rule of rules) {
      if (!seen.has(rule)) {
        seen.add(rule)
        deduplicated.push(rule)
      }
    }
    return deduplicated
  }

  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveRulesetTemplateId(field)
  {
    let seed = this._fieldSeeds.get(field.key)
    if (seed?.templateId) {
      return seed.templateId
    }
    let spec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
        RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
    if (spec?.templateId) {
      return spec.templateId
    }
    if (field.templateId) {
      return field.templateId
    }
    return field._rulesetMain?.templateId ?? ''
  }

  /**
   * @param {ConfigurationField} field
   * @return {void}
   * @private
   */
  _syncRulesetSubjectFromTemplate(field)
  {
    if (field._rulesetSubjectExplicit) {
      return
    }
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (template?.subject) {
      field.rulesetSubject = template.subject
      return
    }
    let spec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
        RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
    if (spec?.rulesetSubject) {
      field.rulesetSubject = spec.rulesetSubject
      return
    }
    delete field.rulesetSubject
  }

  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveRulesetSubject(field)
  {
    if (field._rulesetSubjectExplicit && field.rulesetSubject) {
      return String(field.rulesetSubject).trim().toLowerCase() || 'rule'
    }
    this._syncRulesetSubjectFromTemplate(field)
    if (field.rulesetSubject) {
      return String(field.rulesetSubject).trim().toLowerCase() || 'rule'
    }
    return 'rule'
  }

  /**
   * @param {ConfigurationField} field
   * @return {{subject: string, plural: string, emptyMessage: string, noMatchMessage: string, searchPlaceholder: string, sortHelpText: string, addEntryCaption: string, submitCaption: string}}
   * @private
   */
  _resolveRulesetPanelCopy(field)
  {
    let subject = this._resolveRulesetSubject(field)
    let plural = pluralizeRulesetSubject(subject)
    let subjectTitle = titleCaseRulesetSubject(subject)
    let pluralTitle = titleCaseRulesetSubject(plural)
    return {
      subject,
      plural,
      emptyMessage: `No ${plural} yet.`,
      noMatchMessage: `No matching ${plural}.`,
      searchPlaceholder: `Search ${plural}…`,
      sortHelpText: field.sortHelpText ?? `Sort ${plural}`,
      addEntryCaption: `Add ${subjectTitle}`,
      createDetailTitle: `${field.title ? `${field.title} - ` : ''}Add ${pluralTitle}`,
      submitCaption: `Add ${pluralTitle}`,
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {'create'|'edit'} mode
   * @return {string}
   * @private
   */
  _resolveRulesetDetailTitle(field, mode)
  {
    let copy = this._resolveRulesetPanelCopy(field)
    let prefix = field.title ? `${field.title} - ` : ''
    if (mode === 'edit') {
      return `${prefix}Edit ${titleCaseRulesetSubject(copy.subject)}`
    }
    return `${prefix}Add ${titleCaseRulesetSubject(copy.plural)}`
  }

  /**
   * @param {ConfigurationField} field
   * @return {{fieldKey: string, templateId: string, templateConfig: object, config: object, repos: *}}
   * @private
   */
  _buildRulesetTemplateCtx(field)
  {
    let storedConfig = field._rulesetMain?.config ?? field.config ?? {}
    return {
      fieldKey: field.key,
      templateId: field.templateId ?? field._rulesetMain?.templateId ?? '',
      templateConfig: field.templateConfig ?? field._rulesetMain?.templateConfig ?? {},
      capabilities: {...(field.capabilities ?? {})},
      config: this._sanitizeRulesetUserConfig(storedConfig),
      repos: this._repos,
      rulesetSubject: this._resolveRulesetSubject(field),
    }
  }

  /**
   * IDB-safe subset of template config (runtime callbacks such as `normalizeLine` stay in RAM only).
   * @param {object|null|undefined} config
   * @return {object}
   * @private
   */
  _serializeRulesetTemplateConfig(config)
  {
    let out = {}
    for (let [key, value] of Object.entries(config ?? {})) {
      if (typeof value === 'function') {
        continue
      }
      out[key] = value
    }
    return out
  }

  /**
   * Merge stored IDB template config with in-memory runtime hooks from app registration.
   * @param {object|null|undefined} stored
   * @param {object|null|undefined} runtime
   * @return {object}
   * @private
   */
  _mergeRulesetTemplateConfig(stored, runtime)
  {
    return {...(stored ?? {}), ...(runtime ?? {})}
  }

  /**
   * @param {string} templateId
   * @param {string} tagName
   * @return {*}
   * @private
   */
  _rulesetTemplateRequiresTagEntryId(templateId)
  {
    return templateId === 'tag-blacklist' || templateId === 'explored-tags' ||
        templateId === 'tag-sole-ignore'
  }

  /**
   * Combo sole-attribute templates ({@link createTagComboRulesetTemplate}) — no name-only fallback.
   * @param {string} templateId
   * @return {boolean}
   * @private
   */
  _rulesetMatchRequiresResolvedTagEntry(templateId)
  {
    return templateId === 'tag-blacklist' || templateId === 'explored-tags'
  }

  /**
   * @param {string} templateId
   * @param {string} tagName
   * @return {*}
   * @private
   */
  _rulesetMatchTargetForTag(templateId, tagName)
  {
    if (templateId === 'tag-sole-ignore') {
      return {tagName}
    }
    if (templateId === 'substitution') {
      return {subjectName: tagName}
    }
    if (this._rulesetTemplateRequiresTagEntryId(templateId)) {
      console.warn('[BrazenCM] _rulesetMatchTargetForTag: use _rulesetPayloadForTag with a resolved tag entry for', templateId, tagName)
    }
    return {variant: 'sole', tagName: String(tagName ?? '').trim()}
  }

  /**
   * @param {string} templateId
   * @param {*} tag
   * @return {*}
   * @private
   */
  _rulesetPayloadForTag(templateId, tag)
  {
    if (tag?.entryId == null) {
      console.warn('[BrazenCM] _rulesetPayloadForTag: tag entry missing entryId', templateId, tag)
    }
    if (templateId === 'tag-sole-ignore') {
      return {tagName: tag.name, tagEntryId: tag.entryId}
    }
    return {variant: 'sole', tagName: tag.name, tagEntryId: tag.entryId}
  }

  /**
   * Repair combo-template rows that were persisted before tag entry ids were hydrated.
   * @param {ConfigurationField} field
   * @param {*[]} rows
   * @return {Promise<void>}
   * @private
   */
  async _repairRulesetRowTagEntryIds(field, rows)
  {
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template) {
      return
    }
    if (templateId === 'tag-sole-ignore') {
      this._repos?.meta?.beginTagsRevisionBatch?.()
      try {
        for (let row of rows) {
          let payload = row?.payload
          if (!payload?.tagName || payload.tagEntryId != null) {
            continue
          }
          let tag = await this._repos.tagRuntime.ensureTag(payload.tagName)
          if (tag.entryId == null) {
            continue
          }
          let repaired = {...payload, tagEntryId: tag.entryId}
          let formatted = template.formatForUI(repaired)
          let updated = {
            ...row,
            payload: repaired,
            rawLine: formatted.rawLine ?? row.rawLine,
          }
          await this._repos.rulesetEntries.update(updated)
          row.payload = repaired
          row.rawLine = updated.rawLine
        }
      } finally {
        await this._repos?.meta?.endTagsRevisionBatch?.()
      }
      return
    }
    if (!this._rulesetTemplateRequiresTagEntryId(templateId)) {
      return
    }
    this._repos?.meta?.beginTagsRevisionBatch?.()
    try {
      for (let row of rows) {
        let payload = row?.payload
        if (!payload) {
          continue
        }
        let needsSoleRepair = payload.variant === 'sole' && payload.tagName && payload.tagEntryId == null
        let needsComboRepair = payload.variant === 'combo' && payload.tagNames?.length && !payload.tagEntryIds?.length
        if (!needsSoleRepair && !needsComboRepair) {
          continue
        }
        console.warn('[BrazenCM] repairing ruleset row missing tag entry ids', field.key, row.entryId, payload)
        let repaired = {...payload}
        if (needsSoleRepair) {
          let tag = await this._repos.tagRuntime.ensureTag(repaired.tagName)
          repaired.tagEntryId = tag.entryId
          repaired.tagName = tag.name
        }
        if (needsComboRepair) {
          repaired.tagEntryIds = []
          repaired.tagNames = []
          for (let tagName of payload.tagNames) {
            let tag = await this._repos.tagRuntime.ensureTag(tagName)
            repaired.tagEntryIds.push(tag.entryId)
            repaired.tagNames.push(tag.name)
          }
          if (!repaired.rawLine) {
            repaired.rawLine = repaired.tagNames.join(' & ')
          }
        }
        let formatted = template.formatForUI(repaired)
        let updated = {
          ...row,
          payload: repaired,
          rawLine: formatted.rawLine ?? row.rawLine,
        }
        await this._repos.rulesetEntries.update(updated)
        row.payload = repaired
        row.rawLine = updated.rawLine
      }
    } finally {
      await this._repos?.meta?.endTagsRevisionBatch?.()
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {*} matchTarget
   * @return {*|null}
   * @private
   */
  _findRulesetRowForTarget(field, matchTarget)
  {
    let templateId = field.templateId ?? field._rulesetMain?.templateId
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template) {
      return null
    }
    if (this._rulesetMatchRequiresResolvedTagEntry(templateId)) {
      if (matchTarget?.variant === 'sole' && matchTarget.tagEntryId == null) {
        let targetName = String(matchTarget.tagName ?? '').trim()
        if (targetName) {
          for (let row of field._rulesetRows ?? []) {
            let payload = row.payload
            if (payload?.variant === 'sole' && payload.tagEntryId != null &&
                String(payload.tagName ?? '').trim() === targetName) {
              return row
            }
          }
        }
        return null
      }
      if (matchTarget?.variant === 'combo' &&
          (!Array.isArray(matchTarget.tagEntryIds) || !matchTarget.tagEntryIds.length)) {
        return null
      }
    }
    for (let row of field._rulesetRows ?? []) {
      if (template.matchRule(row.payload, matchTarget)) {
        return row
      }
    }
    return null
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName
   * @param {*} [context]
   * @return {Promise<boolean>}
   * @private
   */
  async _toggleRulesetTagRule(fieldKey, tagName, context = null)
  {
    let field = this.getField(fieldKey)
    let templateId = field?.templateId ?? field?._rulesetMain?.templateId
    if (!field || !templateId) {
      return false
    }
    let normalized = String(tagName ?? '').trim()
    if (!normalized) {
      return false
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    this._repos.tagRuntime?.primeOptimisticEntry?.(normalized)
    field._deferAsyncOptimized = (field._deferAsyncOptimized ?? 0) + 1
    let currentlyActive = this.hasTagSoleAttribute(fieldKey, normalized)
    let tagDetail = this.createTagsChangeDetail(normalized, fieldKey)
    let previousRows = (field._rulesetRows ?? []).slice()
    let previousOptimized = field.optimized
    let previousValue = Array.isArray(field.value) ? field.value.slice() : field.value
    let optimisticEntryId = null
    let syncRemovedEntryId = null

    if (currentlyActive) {
      let syncExisting = this._findSoleAttributeRowForToggle(field, templateId, normalized)
      if (syncExisting?.entryId != null) {
        syncRemovedEntryId = syncExisting.entryId
        field.removeRow(syncExisting.entryId)
      }
      this._syncOptimisticIgnoreValue(field, normalized, false)
    } else {
      optimisticEntryId = typeof crypto !== 'undefined' && crypto.randomUUID ?
          crypto.randomUUID() : `optimistic-${Date.now()}`
      let template = typeof RulesetTemplateRegistry !== 'undefined' ?
          RulesetTemplateRegistry.get(templateId) : null
      let formatted = template?.formatForUI?.({tagName: normalized}) ?? {rawLine: normalized}
      field.patchRow({
        entryId: optimisticEntryId,
        payload: {tagName: normalized, tagEntryId: null, _optimistic: true},
        rawLine: formatted.rawLine ?? normalized,
        sortOrder: Date.now(),
        _optimistic: true,
      })
      this._syncOptimisticIgnoreValue(field, normalized, true)
    }

    try {
      let main = await this._repos.rulesetFields.get(fieldKey)
      let template = typeof RulesetTemplateRegistry !== 'undefined' ?
          RulesetTemplateRegistry.get(main?.templateId ?? templateId) : null
      if (!main?.templateId || !template) {
        return false
      }
      let tag = await this._repos.tagRuntime.ensureTag(normalized, context)
      if (tag.entryId == null) {
        console.warn('[BrazenCM] _toggleRulesetTagRule: ensureTag returned no entryId', fieldKey, normalized)
        throw new Error('_toggleRulesetTagRule: ensureTag returned no entryId')
      }
      let matchTarget = this._rulesetPayloadForTag(template.templateId ?? templateId, tag)
      if (templateId === 'tag-sole-ignore') {
        matchTarget = {...matchTarget, tagName: normalized}
      }
      let existing = this._findRulesetRowForTarget(field, matchTarget)
      if (!existing || existing._optimistic) {
        let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
        if (templateId === 'tag-sole-ignore' && currentlyActive) {
          for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
            let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
                {name: normalized, entryId}
            let candidate = this._rulesetPayloadForTag(templateId, cached)
            existing = rows.find((row) => template.matchRule(row.payload, candidate)) ?? null
            if (existing) {
              break
            }
          }
        }
        if (!existing || existing._optimistic) {
          existing = rows.find((row) => template.matchRule(row.payload, matchTarget)) ?? null
        }
      }
      let mutationEntryIds = []
      let sorted = false
      if (currentlyActive) {
        if (existing?.entryId != null && !existing._optimistic) {
          await this._repos.rulesetEntries.remove(existing.entryId)
          field.removeRow(existing.entryId)
          mutationEntryIds = [existing.entryId]
        }
      } else {
        if (!existing || existing._optimistic) {
          let row = await template.persist(matchTarget, ctx)
          if (optimisticEntryId != null) {
            field._rulesetRows = (field._rulesetRows ?? []).filter((entry) => entry.entryId !== optimisticEntryId)
          }
          field.patchRow(row)
          if (row?.entryId != null) {
            mutationEntryIds = [row.entryId]
          }
          sorted = await this._applyRulesetAutoSortIfEnabled(field)
        } else {
          if (optimisticEntryId != null) {
            field._rulesetRows = (field._rulesetRows ?? []).filter((entry) => entry.entryId !== optimisticEntryId)
          }
          let row = await template.persist(matchTarget, ctx, {entryId: existing.entryId})
          field.patchRow(row)
          mutationEntryIds = [row.entryId]
        }
      }
      if (!sorted && typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, fieldKey)
      }
      await this._refreshRulesetFieldOptimized(field, fieldKey)
      if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
        await this.refreshTagComplianceSpecs([fieldKey])
      }
      this._refreshRulesetFieldUiIfClean(field)
      await this._handleRulesetMutation({
        fieldKeys: [fieldKey],
        entryIds: mutationEntryIds.length ? mutationEntryIds : (syncRemovedEntryId != null ? [syncRemovedEntryId] : []),
        tagNames: [normalized],
      })
      await this._commitTagFieldChange(tagDetail)
      return true
    } catch (error) {
      field._rulesetRows = previousRows
      field.optimized = previousOptimized
      field.value = previousValue
      this._cacheSetting(fieldKey, field.value, field.optimized)
      this.notifyConfigurationChange('tags', true, tagDetail)
      console.log('[BrazenCM] _toggleRulesetTagRule failed:', error)
      throw error
    } finally {
      if (field._deferAsyncOptimized > 0) {
        field._deferAsyncOptimized--
      }
    }
  }

  /**
   * @param {string} fieldKey
   * @param {*} payload
   * @return {Promise<{removedFieldKeys: string[], removedRows: Array<{fieldKey: string, row: *}>}>}
   * @private
   */
  async _resolveRulesetWriteConflicts(fieldKey, payload)
  {
    if (typeof resolveRulesetFieldConflicts !== 'function') {
      return {removedFieldKeys: [], removedRows: []}
    }
    let result = await resolveRulesetFieldConflicts(this._repos, fieldKey, payload)
    for (let item of result.removedRows) {
      this.getField(item.fieldKey)?.removeRow(item.row.entryId)
    }
    return result
  }

  /**
   * @param {{removedFieldKeys: string[], removedRows: Array<{fieldKey: string, row: *}>}} accumulator
   * @param {{removedFieldKeys: string[], removedRows: Array<{fieldKey: string, row: *}>}} result
   * @private
   */
  _accumulateRulesetConflictResults(accumulator, result)
  {
    for (let fieldKey of result.removedFieldKeys ?? []) {
      if (!accumulator.removedFieldKeys.includes(fieldKey)) {
        accumulator.removedFieldKeys.push(fieldKey)
      }
    }
    let seenEntryIds = new Set(accumulator.removedRows.map((item) => item.row.entryId))
    for (let item of result.removedRows ?? []) {
      if (item?.row?.entryId == null || seenEntryIds.has(item.row.entryId)) {
        continue
      }
      seenEntryIds.add(item.row.entryId)
      accumulator.removedRows.push(item)
    }
  }

  /**
   * @param {{removedFieldKeys: string[], removedRows: Array<{fieldKey: string, row: *}>}} accumulator
   * @return {Promise<void>}
   * @private
   */
  async _applyRulesetConflictSideEffects(accumulator)
  {
    let fieldKeys = [...new Set(accumulator.removedFieldKeys ?? [])]
    if (!fieldKeys.length) {
      return
    }
    for (let otherFieldKey of fieldKeys) {
      let other = this.getField(otherFieldKey)
      if (!other) {
        continue
      }
      await other.reload()
      await other.getOptimized()
      other.updateUserInterface()
      if (other.key === 'tag-blacklist' || other.key === 'explored-tags-tracker') {
        await this.refreshTagComplianceSpecs([other.key])
      }
    }
    this.notifyRulesetMutation({
      fieldKeys,
      entryIds: accumulator.removedRows.map((item) => item.row.entryId).filter((entryId) => entryId != null),
    })
    for (let otherFieldKey of fieldKeys) {
      if (this.isTagRegistryField(otherFieldKey)) {
        let tagNames = accumulator.removedRows
            .filter((item) => item.fieldKey === otherFieldKey)
            .flatMap((item) => {
              let other = this.getField(item.fieldKey)
              return other ? this._collectRulesetEntryTagNames(other, item.row) : []
            })
        if (tagNames.length) {
          await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, otherFieldKey))
        }
      } else {
        this.notifyConfigurationChange(otherFieldKey, true)
      }
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {'create'|'edit'} mode
   * @param {*} [entry]
   * @param {HTMLFormElement} form
   * @return {Promise<void>}
   * @private
   */
  async _submitRulesetDetailForm(field, mode, entry, form)
  {
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template) {
      return
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    let values = Object.fromEntries(new FormData(form).entries())
    let payload = template.translateFromUI(values, ctx)
    if (!payload) {
      return
    }
    let groupLabel = String(values.groupLabel ?? '').trim() || null
    let conflictAccumulator = {removedFieldKeys: [], removedRows: []}
    try {
      this._accumulateRulesetConflictResults(
          conflictAccumulator,
          await this._resolveRulesetWriteConflicts(field.key, payload),
      )
      let row = await template.persist(payload, ctx, {
        entryId: mode === 'edit' ? entry?.entryId : null,
        comment: String(values.comment ?? '').trim(),
        groupLabel,
      })
      field.patchRow(row)
      if (!await this._applyRulesetAutoSortIfEnabled(field)) {
        if (typeof compileRulesetField === 'function') {
          await compileRulesetField(this._repos, field.key)
        }
        await field.getOptimized()
      }
      if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
        await this.refreshTagComplianceSpecs([field.key])
      }
      this.notifyRulesetMutation({fieldKeys: [field.key], entryIds: [row.entryId]})
      if (this.isTagRegistryField(field.key)) {
        let tagNames = this._collectRulesetEntryTagNames(field, row)
        if (mode === 'edit' && entry) {
          tagNames = [...this._collectRulesetEntryTagNames(field, entry), ...tagNames]
        }
        await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, field.key))
      } else {
        this.notifyConfigurationChange(field.key, true)
      }
      await this._applyRulesetConflictSideEffects(conflictAccumulator)
      BrazenViewLayer.closeSettingsDetailPane()
      field.updateUserInterface()
    } catch (error) {
      console.log('[BrazenCM] ruleset detail submit failed:', error)
      alert('Could not save this rule. See the browser console for details.')
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {HTMLFormElement[]} forms
   * @return {Promise<void>}
   * @private
   */
  async _submitRulesetDetailForms(field, forms)
  {
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template || !Array.isArray(forms) || !forms.length) {
      return
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    let pending = []
    for (let form of forms) {
      let values = Object.fromEntries(new FormData(form).entries())
      let payload = template.translateFromUI(values, ctx)
      if (!payload) {
        continue
      }
      pending.push({
        payload,
        groupLabel: String(values.groupLabel ?? '').trim() || null,
        comment: String(values.comment ?? '').trim(),
      })
    }
    if (!pending.length) {
      return
    }
    let useAutoSort = !!field.config?.autoSort
    let maxSortOrder = Math.max(0, ...(field._rulesetRows ?? []).map((row) => row.sortOrder ?? 0))
    let sortOrder = maxSortOrder + 1
    let rows = []
    let conflictAccumulator = {removedFieldKeys: [], removedRows: []}
    try {
      for (let item of pending) {
        this._accumulateRulesetConflictResults(
            conflictAccumulator,
            await this._resolveRulesetWriteConflicts(field.key, item.payload),
        )
        let persistMeta = {
          comment: item.comment,
          groupLabel: item.groupLabel,
        }
        if (!useAutoSort) {
          persistMeta.sortOrder = sortOrder++
        }
        let row = await template.persist(item.payload, ctx, persistMeta)
        field.patchRow(row)
        rows.push(row)
      }
      if (useAutoSort) {
        await this._applyRulesetAutoSortIfEnabled(field)
      } else if (typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, field.key)
      }
      if (!useAutoSort) {
        await field.getOptimized()
      }
      if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
        await this.refreshTagComplianceSpecs([field.key])
      }
      this.notifyRulesetMutation({
        fieldKeys: [field.key],
        entryIds: rows.map((row) => row.entryId).filter((entryId) => entryId != null),
      })
      if (this.isTagRegistryField(field.key)) {
        let tagNames = rows.flatMap((row) => this._collectRulesetEntryTagNames(field, row))
        await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, field.key))
      } else {
        this.notifyConfigurationChange(field.key, true)
      }
      await this._applyRulesetConflictSideEffects(conflictAccumulator)
      BrazenViewLayer.closeSettingsDetailPane()
      field.updateUserInterface()
    } catch (error) {
      console.log('[BrazenCM] ruleset detail submit failed:', error)
      alert('Could not save this rule. See the browser console for details.')
    }
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{rawLines: string[], optimized: *}>}
   * @private
   */
  async _compileRulesetFieldFromIdb(fieldKey)
  {
    try {
      let main = await this._repos.rulesetFields.get(fieldKey)
      if (main?.templateId) {
        let compiled = await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
        if (compiled) {
          return compiled
        }
      }
    } catch (error) {
      console.log('[BrazenCM] compileRulesetField failed:', error)
    }
    let stored = await this._repos.settings.getField(fieldKey)
    let field = this._config[fieldKey]
    let value = stored?.value ?? field?.value ?? []
    let rawLines = Array.isArray(value) ? value.map(String) : []
    let optimized = stored?.optimized ?? this._computeFieldOptimized(fieldKey, rawLines)
    return {rawLines, optimized}
  }

  /**
   * Authoritative ruleset compile — invalidates cached compile and applies to the field.
   * @param {ConfigurationField} field
   * @param {string} fieldKey
   * @return {Promise<*>}
   * @private
   */
  async _refreshRulesetFieldOptimized(field, fieldKey)
  {
    this._repos.rulesetFields.invalidateCompiledCache(fieldKey)
    let compiled = await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos, true)
    if (compiled) {
      field.value = compiled.rawLines ?? []
      field.optimized = compiled.optimized ?? null
      this._cacheSetting(fieldKey, field.value, field.optimized)
      return field.optimized
    }
    let fallback = await this._compileRulesetFieldFromIdb(fieldKey)
    field.value = fallback.rawLines ?? []
    field.optimized = fallback.optimized ?? null
    this._cacheSetting(fieldKey, field.value, field.optimized)
    return field.optimized
  }

  /**
   * Collect tag names from visible ruleset rows for TagRuntime warm-before-paint.
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {*[]} entries
   * @return {string[]}
   * @private
   */
  _collectRulesetTagNamesForRender(field, templateId, entries = [])
  {
    let names = new Set()
    let ctx = {
      ...this._buildRulesetTemplateCtx(field),
      tokenize: field.templateConfig?.tokenize ?? field.attributeActionsConfig?.tokenize ?? null,
      normalizeTag: field.templateConfig?.normalizeTag ?? field.attributeActionsConfig?.normalize ?? null,
    }
    for (let entry of entries ?? []) {
      if (typeof collectRulesetWarmTagNames !== 'function') {
        continue
      }
      for (let name of collectRulesetWarmTagNames(entry, templateId, ctx)) {
        if (name) {
          names.add(name)
        }
      }
    }
    return [...names]
  }

  /**
   * Tag names implicated by a ruleset row (sidebar / discovery chrome refresh).
   * @param {ConfigurationField} field
   * @param {*} entry
   * @return {string[]}
   * @private
   */
  _collectRulesetEntryTagNames(field, entry)
  {
    if (!field || !entry) {
      return []
    }
    return this._collectRulesetTagNamesForRender(field, this._resolveRulesetTemplateId(field), [entry])
  }

  /**
   * @param {string} fieldKey
   * @param {{query?: string, groupQuery?: string|null, cursor?: number|null}} pageOptions
   * @return {Promise<{entries: *[], nextCursor: *}>}
   * @private
   */
  async _loadRulesetPage(fieldKey, pageOptions = {})
  {
    let query = String(pageOptions.query ?? '').trim()
    let groupQuery = String(pageOptions.groupQuery ?? '').trim()
    let cursor = pageOptions.cursor ?? null
    if (query || groupQuery) {
      return this._repos.rulesetEntries.search(fieldKey, query, {
        groupLabel: groupQuery || null,
        cursor,
        limit: 50,
      })
    }
    if (cursor != null) {
      return this._repos.rulesetEntries.search(fieldKey, '', {cursor, limit: 50})
    }
    return this._repos.rulesetEntries.listInitial(fieldKey, 50)
  }

  /**
   * @param {ConfigurationField} field
   * @param {number} entryId
   * @return {Promise<void>}
   * @private
   */
  async _removeRulesetEntry(field, entryId)
  {
    let removedEntry = (field._rulesetRows ?? []).find((entry) => entry.entryId === entryId) ?? null
    await this._repos.rulesetEntries.remove(entryId)
    field.removeRow(entryId)
    if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([field.key])
    }
    await field.getOptimized()
    field.updateUserInterface?.()
    this.notifyRulesetMutation({fieldKeys: [field.key], entryIds: [entryId]})
    if (this.isTagRegistryField(field.key)) {
      await this._commitTagFieldChange(this.createTagsChangeDetail(
          this._collectRulesetEntryTagNames(field, removedEntry), field.key))
    } else {
      this.notifyConfigurationChange(field.key, true)
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} groupLabel
   * @return {Promise<void>}
   * @private
   */
  async _removeRulesetGroup(field, groupLabel)
  {
    await this._repos.rulesetEntries.removeGroup(field.key, groupLabel)
    await field.reload()
    field.updateUserInterface()
    this.notifyRulesetMutation({fieldKeys: [field.key]})
    this.notifyConfigurationChange(field.key, true)
  }

  /**
   * @param {string} fieldKey
   * @param {string} ruleLabel
   * @param {function(string): string} normalizeRuleLine
   * @return {boolean}
   */
  matchesTagRuleLabel(fieldKey, ruleLabel, normalizeRuleLine)
  {
    let field = this.getField(fieldKey)
    if (!field || typeof normalizeRuleLine !== 'function') {
      return false
    }
    let lines = Array.isArray(field.value) ? field.value : []
    return lines.some((line) => this._tagRuleLabelMatchesLine(ruleLabel, line, normalizeRuleLine))
  }

  /**
   * @param {string} ruleLabel
   * @param {string} line
   * @param {function(string): string} normalizeRuleLine
   * @return {boolean}
   * @private
   */
  _tagRuleLabelMatchesLine(ruleLabel, line, normalizeRuleLine)
  {
    let normalizedLabel = normalizeRuleLine(ruleLabel)
    let rulePart = line
    let commentIdx = line.indexOf(' // ')
    if (commentIdx >= 0) {
      rulePart = line.slice(0, commentIdx)
    }
    let normalizedRulePart = normalizeRuleLine(rulePart)
    if (normalizedRulePart === normalizedLabel) {
      return true
    }
    if (normalizedRulePart.includes(' | ')) {
      return normalizedRulePart.split(/\s*\|\s*/).includes(normalizedLabel)
    }
    return false
  }

  /**
   * Remove a ruleset row matching an Active Hide Rules label.
   * @param {string} fieldKey
   * @param {string} ruleLabel
   * @param {function(string): string} normalizeRuleLine
   * @return {Promise<boolean>}
   */
  async removeTagRuleByLabel(fieldKey, ruleLabel, normalizeRuleLine)
  {
    let field = this.getField(fieldKey)
    if (!field || typeof normalizeRuleLine !== 'function') {
      return false
    }
    await field.getOptimized?.()
    let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
    let match = rows.find((row) => this._tagRuleLabelMatchesLine(ruleLabel, row.rawLine ?? '', normalizeRuleLine))
    if (!match?.entryId) {
      return false
    }
    await this._removeRulesetEntry(field, match.entryId)
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, fieldKey)
    }
    if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([fieldKey])
    }
    await this._commitTagFieldChange(this.createTagsChangeDetail(null, fieldKey))
    return true
  }

  /**
   * Compile all registered ruleset fields (replaces legacy tagRuleSets compile).
   * @param {string[]} [fieldKeys]
   * @return {Promise<void>}
   */
  async ensureRulesetFieldsCompiled(fieldKeys = null, onProgress = null)
  {
    let keys = fieldKeys ?? RULESET_MIGRATION_FIELD_SPECS.map((spec) => spec.fieldKey)
    let compileKeys = keys.filter((fieldKey) => !!this._config[fieldKey])
    for (let index = 0; index < compileKeys.length; index++) {
      let fieldKey = compileKeys[index]
      if (typeof onProgress === 'function') {
        await onProgress({
          phase: 'ruleset-compile',
          label: 'Compiling rules…',
          detail: fieldKey,
          current: index,
          total: compileKeys.length,
        })
      }
      await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
      let field = this._config[fieldKey]
      if (field?.getOptimized) {
        await field.getOptimized()
      }
    }
    let meta = await this._repos.meta.get()
    this._bootRulesetCompiledConfigSeq = meta?.domainConfigSeq ?? 0
  }

  /**
   * @param {ConfigurationField} field
   * @param {object} partial
   * @return {Promise<void>}
   * @private
   */
  async _updateRulesetFieldConfig(field, partial)
  {
    let sanitizedPartial = {}
    for (let key of RULESET_USER_CONFIG_KEYS) {
      if (partial?.[key] !== undefined) {
        sanitizedPartial[key] = partial[key]
      }
    }
    let previousConfig = {...(field.config ?? {})}
    this._applyRulesetUserConfig(field, {...(field.config ?? {}), ...sanitizedPartial})
    let main = field._rulesetMain ?? await this._repos.rulesetFields.get(field.key)
    await this._repos.rulesetFields.upsert({
      fieldKey: field.key,
      templateId: field.templateId ?? main?.templateId ?? '',
      templateConfig: this._serializeRulesetTemplateConfig(field.templateConfig ?? main?.templateConfig ?? {}),
      config: field.config,
    })
    let shouldResort = field.config.autoSort &&
        (sanitizedPartial.autoSort === true ||
            (sanitizedPartial.sortMode !== undefined && previousConfig.sortMode !== field.config.sortMode))
    if (shouldResort) {
      await this._applyRulesetAutoSortIfEnabled(field)
    }
    field.updateUserInterface()
    this.notifyConfigurationChange(field.key, true)
  }

  /**
   * @param {ConfigurationField} field
   * @return {Promise<boolean>}
   * @private
   */
  async _applyRulesetAutoSortIfEnabled(field)
  {
    if (!field?.config?.autoSort) {
      return false
    }
    if (typeof applyRulesetFieldSort !== 'function') {
      return false
    }
    await applyRulesetFieldSort(this._repos, field.key, field.config.sortMode ?? 'natural-asc')
    await field.reload({skipAutoSortRepair: true})
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, field.key)
    }
    await field.getOptimized()
    return true
  }

  /**
   * @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} detail
   * @return {Promise<void>}
   * @private
   */
  async _handleRulesetMutation(detail = {})
  {
    let keys = detail.fieldKeys ?? []
    if (!keys.length && detail.tagNames?.length) {
      keys = Object.keys(this._config).filter((fieldKey) => this._config[fieldKey].type === CONFIG_TYPE_RULESET)
    }
    for (let fieldKey of keys) {
      let field = this._config[fieldKey]
      if (!field || field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      if (detail.entryIds?.length && field.patchRow && !field.alwaysRefresh && !field._shouldRefreshWidget?.()) {
        for (let entryId of detail.entryIds) {
          let row = await this._repos.storage.get(IDB_STORE_RULESET_ENTRIES, entryId)
          if (row && row.fieldKey === fieldKey) {
            field.patchRow(row)
          } else if (row == null) {
            field.removeRow(entryId)
          }
        }
        await field.getOptimized()
        field.updateUserInterface?.()
        continue
      }
      await field.reload({skipAutoSortRepair: true, skipTagEntryIdRepair: true})
      field.updateUserInterface()
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _refreshRulesetFieldUiIfClean(field)
  {
    if (field?.type !== CONFIG_TYPE_RULESET) {
      return
    }
    if (!field.widget) {
      return
    }
    if (field.alwaysRefresh || field._shouldRefreshWidget?.()) {
      void field.reload({skipAutoSortRepair: true, skipTagEntryIdRepair: true}).
          then(() => field.updateUserInterface())
    }
  }

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

  /**
   * @param {string} name
   * @param {array} keyValuePairs
   * @return {ConfigurationField}
   */
  addCheckboxesGroup(name, keyValuePairs)
  {
    let field = this._createField(CONFIG_TYPE_CHECKBOXES_GROUP, name, [], null)

    field.options = keyValuePairs

    field.createElement = () => {
      field.element = this._uiGen.createFormCheckBoxesGroupSection(field.title, field.options, this._fieldHelpLabelOptions(field))
      return field.element
    }
    field.setFromUserInterface = () => {
      field.value = []
      for (let input of field.element.querySelectorAll('input:checked')) {
        field.value.push(input.getAttribute('data-value'))
      }
    }
    field.updateUserInterface = () => {
      for (let key of field.value) {
        let input = field.element.querySelector('input[data-value="' + key + '"]')
        if (input) {
          input.checked = true
        }
      }
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addColorField(name)
  {
    let field = this._createField(CONFIG_TYPE_COLOR, name, false, null)

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'color', this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.value
    }
    field.updateUserInterface = () => {
      field.element.value = field.value
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addFlagField(name)
  {
    let field = this._createField(CONFIG_TYPE_FLAG, name, false, null)

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'checkbox', this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.checked
    }
    field.updateUserInterface = () => {
      if (field.element) {
        field.element.checked = field.value
      }
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addLedgerField(name)
  {
    let field = this._createField(CONFIG_TYPE_LEDGER, name, null, null)
    let cm = this
    field.persist = false
    /**
     * Bounded positive membership cache (recent claims + primed page hits).
     * Never holds the full ledger — membership is IndexedDB `postId` index lookups.
     * @type {Set<string>}
     */
    field.downloadedIds = new Set()
    /** @type {string[]} */
    field._ledgerCacheOrder = []

    field._ledgerPrimaryField = () => field.ledgerPrimaryField ?? 'ids'
    field._ledgerIsValidId = () => field.ledgerIsValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)

    let rememberHit = (id) => {
      if (!id || field.downloadedIds.has(id)) {
        return
      }
      field.downloadedIds.add(id)
      field._ledgerCacheOrder.push(id)
      let max = (typeof LEDGER_POSITIVE_CACHE_MAX === 'number') ? LEDGER_POSITIVE_CACHE_MAX : 16384
      while (field._ledgerCacheOrder.length > max) {
        let evict = field._ledgerCacheOrder.shift()
        field.downloadedIds.delete(evict)
      }
    }

    field.readIds = (registry) => {
      if (!registry || typeof registry !== 'object') {
        return []
      }
      let primaryField = field._ledgerPrimaryField()
      let ids = []
      let seen = new Set()
      let addValue = (value) => {
        if (value === null || value === undefined) {
          return
        }
        let text = String(value).trim()
        if (!text.length) {
          return
        }
        if (!field._ledgerIsValidId()(text)) {
          let match = text.match(/[?&]id=(\d+)/)
          text = match ? match[1] : null
        }
        if (!text || seen.has(text)) {
          return
        }
        seen.add(text)
        ids.push(text)
      }
      if (Array.isArray(registry[primaryField])) {
        for (let value of registry[primaryField]) {
          addValue(value)
        }
      }
      return ids
    }

    /**
     * Invalidates the bounded positive cache. Does **not** load the ledger into RAM.
     * @return {Promise<ConfigurationField>}
     */
    field.reload = async () => {
      field.downloadedIds = new Set()
      field._ledgerCacheOrder = []
      return field
    }

    /**
     * Batch-prime membership for page tiles / queue ids into the positive cache.
     * @param {Iterable<string|number|null|undefined>} ids
     * @return {Promise<void>}
     */
    field.primeHits = async (ids) => {
      let unique = []
      let seen = new Set()
      for (let raw of ids) {
        if (raw === null || raw === undefined) {
          continue
        }
        let id = String(raw).trim()
        if (!id || seen.has(id) || field.downloadedIds.has(id)) {
          continue
        }
        seen.add(id)
        unique.push(id)
      }
      if (!unique.length) {
        return
      }
      await cm._ensureStorageReady()
      let hits = await cm._repos.ledger.hasMany(unique)
      for (let id of hits) {
        rememberHit(id)
      }
    }

    field.claim = async (downloadIds) => {
      let isValidId = field._ledgerIsValidId()
      let ids = [...new Set(downloadIds
          .filter((id) => id !== null && id !== undefined && String(id).length)
          .map((id) => String(id).trim()))]
      if (!ids.length) {
        return true
      }
      await cm._ensureStorageReady()
      for (let id of ids) {
        if (!isValidId(id)) {
          continue
        }
        if (!(await cm._repos.ledger.claim(id))) {
          return false
        }
        rememberHit(id)
      }
      return true
    }

    field.has = async (id) => {
      if (id === null || id === undefined) {
        return false
      }
      id = String(id).trim()
      if (field.downloadedIds.has(id)) {
        return true
      }
      await cm._ensureStorageReady()
      let hit = await cm._repos.ledger.has(id)
      if (hit) {
        rememberHit(id)
      }
      return hit
    }

    field.merge = async (registry) => {
      if (!registry || typeof registry !== 'object') {
        return
      }
      let ids = field.readIds(registry)
      if (!ids.length) {
        return
      }
      let rows = ids.map((postId) => ({postId, claimedAt: Date.now()}))
      await cm._repos.ledger.mergeRows(rows)
      for (let id of ids) {
        rememberHit(id)
      }
    }

    field.serializeForBackup = async () => {
      await cm._ensureStorageReady()
      let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
      let ids = rows.map((row) => String(row.postId).trim()).filter(Boolean)
      return ids.length ? {[field._ledgerPrimaryField()]: ids} : null
    }

    field.applyFromBackup = (data) => {
      void field.merge(data)
    }

    field.createElement = () => {
      field.element = document.createElement('div')
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}

    return field
  }


  /**
   * @param {string} name
   * @param {int} minimum
   * @param {int} maximum
   * @return {ConfigurationField}
   */
  addNumberField(name, minimum, maximum)
  {
    let field = this._createField(CONFIG_TYPE_NUMBER, name, minimum, null)

    field.minimum = minimum
    field.maximum = maximum

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'number', this._fieldHelpLabelOptions(field))
      let input = inputGroup.querySelector('input')
      input.setAttribute('min', String(field.minimum))
      input.setAttribute('max', String(field.maximum))
      field.element = input
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = Number.parseInt(field.element.value.toString())
    }
    field.updateUserInterface = () => {
      field.element.value = field.value
    }
    return field
  }

  /**
   * @param {string} name
   * @param {array} keyValuePairs
   * @return {ConfigurationField}
   */
  addRadiosGroup(name, keyValuePairs)
  {
    let field = this._createField(CONFIG_TYPE_RADIOS_GROUP, name, keyValuePairs[0][1], null)

    field.options = keyValuePairs

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, this._fieldHelpLabelOptions(field))
      field.element = inputGroup
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let checked = field.element.querySelector('input:checked')
      field.value = checked ? checked.getAttribute('data-value') : null
    }
    field.updateUserInterface = () => {
      let input = field.element.querySelector('input[data-value="' + field.value + '"]')
      if (input) {
        input.checked = true
        input.dispatchEvent(new Event('change', {bubbles: true}))
      }
    }
    return field
  }

  /**
   * @param {string} name
   * @param {int} minimum
   * @param {int} maximum
   * @return {ConfigurationField}
   */
  addRangeField(name, minimum, maximum)
  {
    let field = this._createField(CONFIG_TYPE_RANGE, name, {minimum: minimum, maximum: minimum}, null)

    field.minimum = minimum
    field.maximum = maximum

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormRangeInputGroup(field.title, 'number', field.minimum, field.maximum,
          this._fieldHelpLabelOptions(field))
      field.element = inputGroup
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let inputs = field.element.querySelectorAll('.bv-range-inputs input, :scope > .bv-input.bv-text')
      field.value = {
        minimum: Number.parseInt(inputs[0]?.value, 10) || 0,
        maximum: Number.parseInt(inputs[inputs.length - 1]?.value, 10) || 0,
      }
    }
    field.updateUserInterface = () => {
      let range = this._coerceStoredRangeValue(field.value)
      if (!range) {
        return
      }
      let inputs = field.element.querySelectorAll('.bv-range-inputs input, :scope > .bv-input.bv-text')
      if (inputs[0]) {
        inputs[0].value = range.minimum
      }
      if (inputs.length > 1) {
        inputs[inputs.length - 1].value = range.maximum
      }
    }
    return field
  }

  /**
   * @param {ConfigurationField} field
   * @return {*}
   * @private
   */
  _mountRulesetPanelWidget(field)
  {
    this._syncRulesetSubjectFromTemplate(field)
    let panelCopy = this._resolveRulesetPanelCopy(field)
    let templateId = this._resolveRulesetTemplateId(field)
    let attrConfig = field.attributeActionsConfig
    let host = attrConfig?.host
    field.widget = this._uiGen.createRulesetPanel({
      title: field.title ?? '',
      help: field.help ?? '',
      fieldKey: field.key,
      entryCountLabel: panelCopy.plural,
      resolveEntryCount: ({query, groupQuery}) =>
          this._repos.rulesetEntries.countForField(field.key, {query, groupQuery}),
      addEntryCaption: panelCopy.addEntryCaption,
      createDetailTitle: panelCopy.createDetailTitle,
      emptyMessage: panelCopy.emptyMessage,
      noMatchMessage: panelCopy.noMatchMessage,
      searchPlaceholder: panelCopy.searchPlaceholder,
      sortHelpText: panelCopy.sortHelpText,
      config: field.config,
      sortModes: field.sortModes,
      crudOptions: {
        create: field.showAddButton === false ? false : (field.crudOptions?.create !== false),
        edit: templateId === 'bookmarks' ?
            false :
            (field.crudOptions?.edit !== false),
        delete: field.crudOptions?.delete !== false,
      },
      configControls: {
        autoSort: true,
        sortMode: true,
        groupingEnabled: field.capabilities?.grouping ? true : 'hidden',
        hideTagTypes: true,
      },
      configOptionHelp: field.configOptionHelp ?? {},
      pageMatch: field.pageMatch ?? null,
      onSort: templateId === 'bookmarks' ?
          null :
          (field.onSort ? () => Utilities.callEventHandler(field.onSort, []) : null),
      loadPage: async (pageOptions = {}) => {
        let query = String(pageOptions.query ?? '').trim()
        let groupQuery = String(pageOptions.groupQuery ?? '').trim()
        if (!query && !groupQuery && pageOptions.cursor == null && field.config?.autoSort &&
            typeof applyRulesetFieldSort === 'function') {
          await applyRulesetFieldSort(this._repos, field.key, field.config.sortMode ?? 'natural-asc')
        }
        let page = await this._loadRulesetPage(field.key, pageOptions)
        if (!query && !groupQuery && pageOptions.cursor == null) {
          field._rulesetRows = page.entries ?? []
          field._rulesetNextCursor = page.nextCursor ?? null
        }
        return page
      },
      renderEntryContent: (entry, renderOptions = {}) => {
        let template = typeof RulesetTemplateRegistry !== 'undefined' ?
            RulesetTemplateRegistry.get(templateId) : null
        if (template?.renderRowContent) {
          let baseCtx = this._buildRulesetTemplateCtx(field)
          let ctx = {
            ...baseCtx,
            useNativeTabLink: !field.onNavigate,
            config: {
              ...baseCtx.config,
              hideTagTypes: !!renderOptions.hideTagTypes,
            },
            tokenize: field.templateConfig?.tokenize ?? attrConfig?.tokenize ?? null,
            normalizeTag: field.templateConfig?.normalizeTag ?? attrConfig?.normalize ?? null,
            onNavigate: (url) => {
              if (field.onNavigate) {
                field.onNavigate(url)
              } else if (url) {
                Utilities.openUrlInNewTab(url)
              }
            },
          }
          return template.renderRowContent(entry, ctx)
        }
        return entry.rawLine ?? ''
      },
      getRowActions: attrConfig ? (entry) => {
        let row = {
          entryId: entry.entryId,
          label: entry?.payload?.label ?? entry?.rawLine ?? '',
          tags: entry?.payload?.tags ?? '',
          url: entry?.payload?.url ?? '',
        }
        let frameworkActions = []
        if (host && typeof host.createBookmarkRowAttributeActions === 'function') {
          frameworkActions = host.createBookmarkRowAttributeActions(row, attrConfig) ?? []
        }
        let consumerActions = Utilities.callEventHandler(field.getRowActions, [row], []) ?? []
        return [...frameworkActions, ...consumerActions]
      } : () => [],
      onBeforeRender: (entries) => {
        let warmTagNames = this._collectRulesetTagNamesForRender(field, templateId, entries)
        let warmPromise = warmTagNames.length && this._repos?.tagRuntime?.ensureNames ?
            this._repos.tagRuntime.ensureNames(warmTagNames) :
            Promise.resolve()
        if (attrConfig) {
          let rows = (entries ?? []).map((entry) => ({
            entryId: entry.entryId,
            label: entry?.payload?.label ?? entry?.rawLine ?? '',
            tags: entry?.payload?.tags ?? '',
            url: entry?.payload?.url ?? '',
          }))
          let runConsumer = () => Utilities.callEventHandler(field.onBeforeRender, [rows])
          if (!host || typeof host.prepareBookmarkRowAttributeData !== 'function') {
            return warmPromise.then(() => runConsumer())
          }
          return warmPromise.then(() => host.prepareBookmarkRowAttributeData(rows, {
            tokenize: attrConfig.tokenize,
            normalize: attrConfig.normalize,
            attemptedKey: attrConfig.attemptedKey ?? field.key,
            blacklist: attrConfig.blacklist,
            explore: attrConfig.explore,
          }).then(() => runConsumer()))
        }
        return warmPromise.then(() => Utilities.callEventHandler(field.onBeforeRender, [entries]))
      },
      onAddGroup: () => {
        let groups = [...new Set((field._rulesetRows ?? []).map((row) => row.groupLabel).filter(Boolean))]
        let picker = BrazenViewLayer.createGroupCombobox({groups})
        BrazenViewLayer.openSettingsDetailPane({
          title: 'Add group',
          content: Utilities.makeEl('div', {children: [picker]}),
        })
      },
      onAddRule: () => field.openDetailForm('create', {fieldKey: field.key}),
      onEdit: (entry) => field.openDetailForm('edit', {fieldKey: field.key, entry}),
      onRemove: (entryId) => {
        if (field.onRemove) {
          Utilities.callEventHandler(field.onRemove, [entryId])
          return
        }
        void this._removeRulesetEntry(field, entryId)
      },
      onRemoveGroup: (label) => void this._removeRulesetGroup(field, label),
      onConfigChange: (partial) => void this._updateRulesetFieldConfig(field, partial),
      detailFormTemplate: field.detailFormTemplate,
    })
    field.element = field.widget.element
    return field.widget
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addRulesetField(name)
  {
    let field = this._createField(CONFIG_TYPE_RULESET, name, [], null)
    let cm = this
    field.persist = false
    field.widget = null
    field._rulesetRows = []
    field._rulesetNextCursor = null
    field._reloadGeneration = 0
    field._rulesetMain = null
    field.templateId = ''
    field.templateConfig = {}
    field.capabilities = {}
    field.config = {}
    field.configOptionHelp = {}
    field.sortModes = [
      {value: 'natural-asc', label: 'Natural A→Z'},
      {value: 'natural-desc', label: 'Natural Z→A'},
    ]
    field.crudOptions = {create: true, edit: true, delete: true}
    field.alwaysRefresh = false
    field.detailFormTemplate = null
    field.optimized = null
    field._rulesetTagIndex = new Map()

    let rulesetSpec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
        RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
    if (!field.templateId && rulesetSpec?.templateId) {
      field.templateId = rulesetSpec.templateId
    }
    cm._syncRulesetSubjectFromTemplate(field)

    field.rebuildRulesetTagIndex = () => {
      let index = new Map()
      let attrConfig = field.attributeActionsConfig
      let tokenize = attrConfig?.tokenize
      let normalize = attrConfig?.normalize ?? ((value) => String(value ?? '').trim())
      if (typeof tokenize === 'function') {
        for (let entry of field._rulesetRows ?? []) {
          let tokens = tokenize(entry?.payload?.tags ?? '')
          if (!Array.isArray(tokens)) {
            continue
          }
          for (let token of tokens) {
            let tagName = normalize(token)
            if (tagName) {
              index.set(tagName, true)
            }
          }
        }
      }
      field._rulesetTagIndex = index
    }

    field._shouldRefreshWidget = () => {
      if (!field.widget) {
        return false
      }
      if (field.alwaysRefresh) {
        return true
      }
      let active = document.activeElement
      return !!(active && field.widget.element?.contains(active))
    }

    field.getOptimized = async () => {
      let compiled = await cm._compileRulesetFieldFromIdb(field.key)
      field.value = compiled?.rawLines ?? []
      field.optimized = compiled?.optimized ?? null
      cm._cacheSetting(field.key, field.value, field.optimized)
      return field.optimized
    }

    field.reload = async (options = {}) => {
      let generation = ++field._reloadGeneration
      try {
        let main = await cm._repos.rulesetFields.get(field.key)
        if (generation !== field._reloadGeneration) {
          return field
        }
        if (main) {
          field._rulesetMain = main
          field.templateId = cm._resolveRulesetTemplateId(field)
          field.templateConfig = cm._mergeRulesetTemplateConfig(main.templateConfig, field.templateConfig)
          cm._applyRulesetUserConfig(field, main.config)
          cm._syncRulesetSubjectFromTemplate(field)
          field.widget?.updatePanelCopy?.(cm._resolveRulesetPanelCopy(field))
          field.widget?.updateConfig?.(field.config)
        }
        let shouldRepairSort = !options.skipAutoSortRepair &&
            !options.query &&
            !options.groupQuery &&
            options.cursor == null &&
            field.config?.autoSort &&
            typeof applyRulesetFieldSort === 'function'
        if (shouldRepairSort) {
          await applyRulesetFieldSort(cm._repos, field.key, field.config.sortMode ?? 'natural-asc')
        }
        let page
        if (options.query || options.groupQuery || options.cursor != null) {
          page = await cm._loadRulesetPage(field.key, options)
        } else {
          page = await cm._repos.rulesetEntries.listInitial(field.key, 50)
        }
        if (generation !== field._reloadGeneration) {
          return field
        }
        field._rulesetRows = page.entries ?? []
        field._rulesetNextCursor = page.nextCursor ?? null
        if (!options.skipTagEntryIdRepair) {
          await cm._repairRulesetRowTagEntryIds(field, field._rulesetRows)
        }
        await field.getOptimized()
        field.rebuildRulesetTagIndex()
      } catch (error) {
        console.log('Failed to load ruleset field:', error)
      }
      return field
    }

    field.patchRow = (row) => {
      if (!row || row.entryId == null) {
        return field
      }
      let index = field._rulesetRows.findIndex((entry) => entry.entryId === row.entryId)
      if (index >= 0) {
        field._rulesetRows[index] = row
      } else {
        field._rulesetRows.push(row)
      }
      field._rulesetRows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
      if (field._shouldRefreshWidget()) {
        field.updateUserInterface()
      }
      if (!row._optimistic && !field._deferAsyncOptimized) {
        void field.getOptimized()
      }
      return field
    }

    field.removeRow = (entryId) => {
      field._rulesetRows = field._rulesetRows.filter((entry) => entry.entryId !== entryId)
      if (field._shouldRefreshWidget()) {
        field.updateUserInterface()
      }
      if (!field._deferAsyncOptimized) {
        void field.getOptimized()
      }
      return field
    }

    field.findRuleIndex = (target) => {
      let templateId = field.templateId ?? field._rulesetMain?.templateId
      let matchTarget
      if (typeof target === 'string') {
        if (cm._rulesetTemplateRequiresTagEntryId(templateId)) {
          let cached = cm.getCachedTagEntry(target)
          if (cached?.entryId == null) {
            return -1
          }
          matchTarget = cm._rulesetPayloadForTag(templateId, cached)
        } else {
          matchTarget = cm._rulesetMatchTargetForTag(templateId, target)
        }
      } else {
        matchTarget = target
      }
      for (let index = 0; index < (field._rulesetRows ?? []).length; index++) {
        let entry = field._rulesetRows[index]
        if (typeof RulesetTemplateRegistry !== 'undefined') {
          let template = RulesetTemplateRegistry.get(templateId)
          if (template?.matchRule?.(entry.payload, matchTarget)) {
            return index
          }
        }
      }
      return -1
    }
    field.hasRule = (target) => field.findRuleIndex(target) >= 0
    field.setRules = () => {
      void field.reload()
    }
    field.addRule = () => {
      void field.reload()
    }
    field.removeRule = () => {
      void field.reload()
    }
    field.toggleRule = () => {
      void field.reload()
    }
    field.clearRules = () => {
      void field.reload()
    }

    field.serializeForBackup = async () => {
      await field.reload()
      let main = field._rulesetMain ?? await cm._repos.rulesetFields.get(field.key)
      let entries = await cm._repos.rulesetEntries.listAllForField(field.key)
      return main || entries.length ? {main, entries} : null
    }

    field.applyFromBackup = async (data) => {
      if (Array.isArray(data)) {
        await cm._importTagFieldFromValue(field.key, data)
        return
      }
      if (!data) {
        return
      }
      if (data.main) {
        await cm._repos.rulesetFields.upsert(data.main)
      } else if (data.config) {
        let main = field._rulesetMain ?? await cm._repos.rulesetFields.get(field.key)
        await cm._repos.rulesetFields.upsert({
          fieldKey: field.key,
          templateId: main?.templateId ?? field.templateId ?? '',
          templateConfig: main?.templateConfig ?? field.templateConfig ?? {},
          config: data.config,
        })
      }
      if (Array.isArray(data.entries)) {
        for (let entry of data.entries) {
          if (entry.entryId != null) {
            await cm._repos.rulesetEntries.update(entry)
          } else {
            await cm._repos.rulesetEntries.add(field.key, entry)
          }
        }
      }
      await field.reload()
      field.widget?.render(field._rulesetRows)
    }

    field.createElement = () => {
      field.widget?.dispose?.()
      if (typeof cm._uiGen.createRulesetPanel !== 'function') {
        field.element = Utilities.makeEl('div', {
          class: 'bv-ruleset-panel bv-ruleset-panel-placeholder',
          text: field.title ?? 'Ruleset',
        })
        void field.reload().then(() => field.updateUserInterface())
        return field.element
      }
      cm._mountRulesetPanelWidget(field)
      field.element = field.widget.element
      void field.reload().then(() => field.updateUserInterface())
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {
      if (!field.widget) {
        return
      }
      field.widget.render(field._rulesetRows, {
        nextCursor: field._rulesetNextCursor,
        forceRepaint: true,
      })
      field.widget.checkPageMatch?.()
      void field.widget.refreshEntryCount?.()
    }
    field.isCurrentPagePresent = (url) => field.widget?.isCurrentPagePresent?.(url) ?? false
    field.dispose = () => {
      field.widget?.dispose?.()
      field.widget = null
      field.element = null
      field._rulesetRows = []
      field._rulesetNextCursor = null
    }

    if (!field.detailFormTemplate) {
      field.setDetailFormTemplate((mode, ctx) => {
        let template = typeof RulesetTemplateRegistry !== 'undefined' ?
            RulesetTemplateRegistry.get(cm._resolveRulesetTemplateId(field)) : null
        if (!template) {
          return Utilities.makeEl('div', {text: 'Ruleset template unavailable.'})
        }
        let templateCtx = {
          ...cm._buildRulesetTemplateCtx(field),
          existingGroups: [...new Set((field._rulesetRows ?? []).map((row) => row.groupLabel).filter(Boolean))],
          entryComment: mode === 'edit' ? String(ctx.entry?.comment ?? '') : '',
        }
        let panelCopy = cm._resolveRulesetPanelCopy(field)
        if (mode === 'create') {
          return BrazenViewLayer.createRulesetDetailMultiForm({
            addEntryCaption: panelCopy.addEntryCaption,
            submitCaption: panelCopy.submitCaption,
            createEntryForm: () => template.createForm(templateCtx),
            onSubmit: (forms) => void cm._submitRulesetDetailForms(field, forms),
          })
        }
        let form = template.editForm(ctx.entry, templateCtx)
        let wrapped = BrazenViewLayer.wrapRulesetDetailEntry(form)
        let footer = Utilities.makeEl('div', {
          class: 'bv-ruleset-detail-footer',
          children: [
            Utilities.makeEl('button', {
              class: 'bv-button bv-ruleset-detail-submit',
              text: 'Save',
              attrs: {type: 'button', title: 'Save changes'},
              on: {
                click: (event) => {
                  let root = event.currentTarget.closest('.bv-ruleset-detail-single')
                  let formEl = root?.querySelector('form.bv-ruleset-form, .bv-ruleset-form') ?? form
                  void cm._submitRulesetDetailForm(field, mode, ctx.entry, formEl)
                },
              },
            }),
          ],
        })
        return Utilities.makeEl('div', {
          class: 'bv-ruleset-detail-single',
          children: [wrapped, footer],
        })
      })
    }

    return field
  }

  /**
   * @param {string} name
   * @param {array} keyValuePairs
   * @return {ConfigurationField}
   */
  addSelectField(name, keyValuePairs)
  {
    let field = this._createField(CONFIG_TYPE_SELECT, name, keyValuePairs[0][1], null)

    field.options = keyValuePairs

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('select')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.value
    }
    field.updateUserInterface = () => {
      field.element.value = field.value
      field.element.dispatchEvent(new Event('change', {bubbles: true}))
    }
    return field
  }

  /**
   * Settings field persisted via {@link readSetting}/{@link writeSetting} only — no panel UI.
   * @param {string} name
   * @param {*} [defaultValue]
   * @return {ConfigurationField}
   */
  addHeadlessSettingField(name, defaultValue = '')
  {
    let field = this._createField(CONFIG_TYPE_TEXT, name, defaultValue, null)
    field.headless = true
    field.textDefault = defaultValue ?? ''

    field.createElement = () => null
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addTextField(name)
  {
    let field = this._createField(CONFIG_TYPE_TEXT, name, '', null)
    field.textDefault = ''

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'text', this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let value = field.element.value
      field.value = value === '' ? field.textDefault : value
    }
    field.updateUserInterface = () => {
      field.element.value = field.value
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addActionField(name)
  {
    let field = this._createField(CONFIG_TYPE_ACTION, name, null, null)
    field.persist = false

    field.createElement = () => {
      if (this._dockActive && field.dock) {
        field.element = null
        return null
      }
      field.element = this._uiGen.createFormButton(field.title, typeof field.help === 'string' ? field.help : '', () => {
        Utilities.callEventHandler(field.onClick, [])
      })
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}
    return field
  }

  /**
   * True when any cached ruleset row tokenizes to one of the given tag names.
   * @param {string|string[]} tags
   * @param {string} [fieldKey]
   * @return {boolean}
   */
  rulesetContainsAnyTag(tags, fieldKey = null)
  {
    let names = Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])
    if (!names.length) {
      return false
    }
    for (let field of Object.values(this._config)) {
      if (field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      if (fieldKey && field.key !== fieldKey) {
        continue
      }
      let index = field._rulesetTagIndex
      if (!(index instanceof Map) || !index.size) {
        field.rebuildRulesetTagIndex?.()
        index = field._rulesetTagIndex
      }
      if (!(index instanceof Map)) {
        continue
      }
      for (let name of names) {
        if (name && index.has(name)) {
          return true
        }
      }
    }
    return false
  }


  /**
   * @return {ConfigurationField[]}
   */
  getDockRootFields()
  {
    let fields = []
    for (let key in this._config) {
      let field = this._config[key]
      if (field.dock && !field.dock.parent) {
        fields.push(field)
      }
    }
    return fields
  }

  /**
   * Ordered keys of root dock fields that should be mounted under the current include context.
   * Used to decide whether a dock rail relayout is required vs state-only refresh.
   * @return {string}
   */
  getDockRailMembershipSignature()
  {
    return this.getDockRootFields().
        filter((field) => !field.dock?.railHead).
        filter((field) => this._evaluateDockInclude(field)).
        map((field) => field.key).
        join('|')
  }

  /**
   * @param {string} name
   * @returns {HTMLElement|null}
   */
  createElement(name)
  {
    let field = this.getFieldOrFail(name)
    if (this._dockActive && field.dock) {
      field.element = null
      return null
    }
    if (this._storageReady) {
      void this._syncFieldFromIdb(this._formatFieldKey(name)).then(() => {
        this._overlayFieldFromCache(field)
        if (field.element) {
          field.updateUserInterface()
        }
      })
    }
    let element = field.createElement()
    this._warnIfMissingHelp(field)
    return element
  }

  /**
   * @param {string} name
   * @return {HTMLElement|null}
   */
  createDockElement(name)
  {
    let field = this.getFieldOrFail(name)
    if (!field.dock) {
      return null
    }
    if (field.dock.parent) {
      return null
    }
    if (!this._evaluateDockInclude(field)) {
      return null
    }

    let dockButton = this._buildDockButtonNode(field)
    let childFields = this._getDockChildFields(field.key)
    let childButtons = []
    if ((childFields.length || field.dock.getSlideOutNodes) && this._shouldShowDockSlideOut(field)) {
      if (field.dock.getSlideOutNodes) {
        let customNodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
        if (Array.isArray(customNodes)) {
          for (let node of customNodes) {
            if (node != null) {
              childButtons.push(node)
            }
          }
        }
      }
      for (let childField of childFields) {
        let childNode = this._createDockChildElement(childField)
        if (childNode) {
          childButtons.push(childNode)
        }
      }
      if (childButtons.length) {
        if (field.dock.slideOutInsetGroup) {
          childButtons = [this._uiGen.createDockInsetGroup(childButtons)]
        }
        dockButton = this._uiGen.createDockSlideOut(dockButton, childButtons)
        if (this._isDockSlideOutPinned(field)) {
          dockButton.classList.add('bv-dock-slideout-pinned')
        }
      }
    }

    field._dockShowsSlideOut = childButtons.length > 0
    field._dockSlideOutChildSignature = this._getDockSlideOutChildSignature(field)
    field.dockElement = dockButton
    return dockButton
  }

  /**
   * @param {ConfigurationField} childField
   * @return {HTMLElement|null}
   * @private
   */
  _createDockChildElement(childField)
  {
    if (!childField.dock) {
      return null
    }
    if (!this._evaluateDockInclude(childField)) {
      return null
    }
    let childButton = this._buildDockButtonNode(childField)
    childField.dockElement = childButton
    return childButton
  }

  /**
   * @param {ConfigurationField} field
   * @return {HTMLElement}
   * @private
   */
  _buildDockButtonNode(field)
  {
    let value = this._getEffectiveFieldValue(field)
    let stateClass = this._resolveDockStateClass(field, value)
    let tooltip = this._resolveDockTooltip(field, value)
    let icon = typeof field.dock.icon === 'function' ? field.dock.icon(value, field) : field.dock.icon
    let button = this._uiGen.createDockButton({
      icon: icon,
      tooltip: tooltip,
      stateClass: stateClass,
      disabled: this._resolveDockDisabled(field, value),
      onClick: () => this._handleDockButtonClick(field),
    })
    if (field.dock.buttonClass) {
      for (let cls of field.dock.buttonClass.split(/\s+/)) {
        if (cls) {
          button.classList.add(cls)
        }
      }
    }
    return button
  }

  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveDockStateClass(field, value = null)
  {
    value = value ?? this._getEffectiveFieldValue(field)
    if (field.dock.getState) {
      return field.dock.getState(value, field) ?? ''
    }
    if (field.dock.isActive !== undefined) {
      let active = typeof field.dock.isActive === 'function' ? field.dock.isActive(value, field) : field.dock.isActive
      return active ? 'bv-dock-btn-active' : ''
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      return value ? 'bv-dock-btn-active' : ''
    }
    return ''
  }

  /**
   * @param {ConfigurationField} field
   * @param {*} value
   * @return {string}
   * @private
   */
  _resolveDockTooltip(field, value = null)
  {
    value = value ?? this._getEffectiveFieldValue(field)
    if (typeof field.dock.tooltip === 'function') {
      return field.dock.tooltip(value, field) ?? field.title
    }
    return field.dock.tooltip ?? field.title
  }

  /**
   * @param {ConfigurationField} field
   * @param {*} [value]
   * @return {boolean}
   * @private
   */
  _resolveDockDisabled(field, value = null)
  {
    if (field.dock.isDisabled === undefined) {
      return false
    }
    value = value ?? this._getEffectiveFieldValue(field)
    if (typeof field.dock.isDisabled === 'function') {
      if (this._dockIncludeContext) {
        return !!field.dock.isDisabled.call(this._dockIncludeContext, value, field)
      }
      return !!field.dock.isDisabled(value, field)
    }
    return !!field.dock.isDisabled
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _isDockFieldDisabled(field)
  {
    this._overlayFieldFromCache(field)
    return this._resolveDockDisabled(field, this._getEffectiveFieldValue(field))
  }

  /**
   * Release focus from slide-out child buttons so the slot can collapse (CSS uses :focus-within).
   * @private
   */
  _blurDockSlideOutFocus()
  {
    let active = document.activeElement
    if (active instanceof HTMLElement && active.closest('.bv-dock-slot')) {
      active.blur()
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _handleDockButtonClick(field)
  {
    if (this._isDockFieldDisabled(field)) {
      return
    }
    if (field.dock.onClick) {
      void this._finishDockButtonClick(Promise.resolve(Utilities.callEventHandler(field.dock.onClick, [field])))
      return
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      this._blurDockSlideOutFocus()
      void this._toggleDockFlagFromIdb(field)
      return
    }
    if (field.type === CONFIG_TYPE_ACTION && field.onClick) {
      void this._finishDockButtonClick(Promise.resolve(Utilities.callEventHandler(field.onClick, [])))
    }
  }

  /**
   * @param {Promise<*>} promise
   * @return {Promise<void>}
   * @private
   */
  async _finishDockButtonClick(promise)
  {
    try {
      await promise
    } finally {
      this._blurDockSlideOutFocus()
      this.refreshDockButtonStates()
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  async _toggleDockFlagFromIdb(field)
  {
    let current = await this.readSetting(field.key)
    if (current === null || current === undefined) {
      current = !!field.value
    }
    await this.writeSetting(field.key, !current)
    Utilities.callEventHandler(this._onDockToggle, [field])
    this.refreshDockButtonStates()
  }

  /**
   * @param {string} parentTitle
   * @return {ConfigurationField[]}
   * @private
   */
  _getDockChildFields(parentKey)
  {
    let parentField = this.getField(parentKey)
    let children = []
    if (parentField?.dockSlideOutChildren) {
      for (let childName of parentField.dockSlideOutChildren) {
        let child = this.getField(childName)
        if (!child) {
          continue
        }
        if (child.dock?.parent !== parentKey) {
          child.dock = {...(child.dock ?? {}), parent: parentKey}
        }
        children.push(child)
      }
    }
    for (let key in this._config) {
      let field = this._config[key]
      if (field.dock?.parent === parentKey && !children.includes(field)) {
        children.push(field)
      }
    }
    return children
  }

  /**
   * @param {boolean} value
   * @return {string}
   * @private
   */
  _dockFlagState(value)
  {
    return value ? 'bv-dock-btn-active' : ''
  }

  /**
   * @param {string} label
   * @param {boolean} value
   * @param {string} whenOn
   * @param {string} whenOff
   * @return {string}
   * @private
   */
  _dockFlagTooltip(label, value, whenOn, whenOff)
  {
    return value ? `${label}: on — ${whenOn}` : `${label}: off — ${whenOff}`
  }

  /**
   * @param {{icon: string, label: string, whenOn: string, whenOff: string, include?: Function}} spec
   * @return {object}
   * @private
   */
  _dockFlagToggleRecipe(spec)
  {
    let recipe = {
      icon: spec.icon,
      getState: (value) => this._dockFlagState(value),
      tooltip: (value) => this._dockFlagTooltip(spec.label, value, spec.whenOn, spec.whenOff),
    }
    if (spec.include !== undefined) {
      recipe.include = spec.include
    }
    return recipe
  }

  /**
   * Named dock-button recipes with baked copy. Consumers call by name only;
   * pass sparse `args` solely when a real override case is added to the API.
   *
   * @param {ConfigurationField} field
   * @param {string} name
   * @param {object} [args]
   * @private
   */
  _applyDockTemplate(field, name, args = {})
  {
    let searchPageInclude = function() {
      return typeof this.isPage === 'function' ? this.isPage('search') : true
    }
    let downloadManagerInclude = function() {
      return typeof this.isDownloadManagerEnabled === 'function' ? this.isDownloadManagerEnabled() : true
    }

    let recipe = null
    switch (name) {
      case 'flagToggle':
        // Generic escape hatch — prefer named templates with baked copy.
        recipe = this._dockFlagToggleRecipe({
          icon: args.icon ?? 'block',
          label: args.label ?? field.title ?? field.key,
          whenOn: args.whenOn ?? 'click to turn off',
          whenOff: args.whenOff ?? 'click to turn on',
          include: args.include,
        })
        break
      case 'tagBlacklist':
        recipe = this._dockFlagToggleRecipe({
          icon: 'block',
          label: 'Tag blacklist',
          whenOn: 'click to stop hiding matching posts',
          whenOff: 'click to hide posts with listed tags',
        })
        break
      case 'exploredTags':
        recipe = this._dockFlagToggleRecipe({
          icon: 'explore',
          label: 'Explored tags',
          whenOn: 'click to stop tracking while paging',
          whenOff: 'click to track while paging',
          include: searchPageInclude,
        })
        break
      case 'autoNextPage':
        recipe = this._dockFlagToggleRecipe({
          icon: 'next',
          label: 'Auto next page',
          whenOn: 'click to stop advancing when page is empty',
          whenOff: 'click to go to next page when all results filtered',
          include: searchPageInclude,
        })
        break
      case 'defaultTags':
        recipe = this._dockFlagToggleRecipe({
          icon: 'inject',
          label: 'Default tags',
          whenOn: 'click to stop injecting into searches',
          whenOff: 'click to inject into every search',
        })
        break
      case 'resolutionFilter':
        recipe = this._dockFlagToggleRecipe({
          icon: 'resolution',
          label: 'Resolution filter',
          whenOn: 'click to stop restricting width / height',
          whenOff: 'click to limit posts to size range',
        })
        break
      case 'hideOlderPosts':
        recipe = this._dockFlagToggleRecipe({
          icon: 'history',
          label: 'Hide older posts',
          whenOn: 'click to show posts below Last ID',
          whenOff: 'click to hide posts below Last ID',
        })
        break
      case 'invertedFiltersMaster':
        recipe = {
          icon: 'filter',
          isActive: (value) => !value,
          slideOutWhen: (value) => !value,
          tooltip: (value) => value ?
              'Filters bypassed — click to re-enable' :
              'Search filters active — hover for sub-filters',
          include: searchPageInclude,
        }
        break
      case 'skipDuplicates':
        recipe = {
          icon: 'skip-duplicate',
          getState: (value) => this._dockFlagState(value),
          tooltip: (value) => value ?
              'Skip duplicates: on — click to allow re-downloads; hover for Hide Downloaded Media' :
              'Skip duplicates: off — click to skip ledger hits; hover for Hide Downloaded Media',
          slideOutWhen: () => true,
          include: downloadManagerInclude,
        }
        break
      case 'hideDownloaded':
        recipe = this._dockFlagToggleRecipe({
          icon: 'eye-off',
          label: 'Hide downloaded',
          whenOn: 'click to show posts already in the download ledger',
          whenOff: 'click to hide posts already in the download ledger',
          include: downloadManagerInclude,
        })
        break
      default:
        console.warn('Unknown dock template:', name)
        return
    }

    // Sparse dock-behavior overrides only (e.g. onClick). Recipe copy stays in the template.
    let {
      icon: _icon,
      label: _label,
      whenOn: _whenOn,
      whenOff: _whenOff,
      ...dockOverrides
    } = args
    field.dock = {...(field.dock ?? {}), ...recipe, ...dockOverrides, templateName: name}
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _shouldShowDockSlideOut(field)
  {
    if (field.dock.slideOutWhen) {
      return !!Utilities.callEventHandler(field.dock.slideOutWhen, [field.value, field])
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      return !!field.value
    }
    return true
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _isDockSlideOutPinned(field)
  {
    if (field.dock.slideOutPinnedWhen) {
      return !!Utilities.callEventHandler(field.dock.slideOutPinnedWhen, [field.value, field])
    }
    return false
  }

  /**
   * @param {boolean} active
   * @return {BrazenConfigurationManager}
   */
  setDockActive(active)
  {
    this._dockActive = active
    return this
  }

  /**
   * @return {boolean}
   */
  isDockActive()
  {
    return this._dockActive
  }

  /**
   * @param {object|null} context
   * @return {BrazenConfigurationManager}
   */
  setDockIncludeContext(context)
  {
    this._dockIncludeContext = context ?? null
    return this
  }

  /**
   * @param {Function} callback
   * @return {BrazenConfigurationManager}
   */
  onDockToggle(callback)
  {
    if (!this._onDockToggle) {
      this._onDockToggle = callback
    } else {
      let existing = this._onDockToggle
      this._onDockToggle = (field) => {
        Utilities.callEventHandler(existing, [field])
        Utilities.callEventHandler(callback, [field])
      }
    }
    return this
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _computeDockShowsSlideOut(field)
  {
    if (!this._shouldShowDockSlideOut(field)) {
      return false
    }
    if (field.dock.getSlideOutNodes) {
      let nodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
      if (Array.isArray(nodes) && nodes.some((node) => node != null)) {
        return true
      }
    }
    for (let childField of this._getDockChildFields(field.key)) {
      if (this._evaluateDockInclude(childField)) {
        return true
      }
    }
    return false
  }

  /**
   * Keys of slide-out children (and custom nodes) that would mount under the current include context.
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _getDockSlideOutChildSignature(field)
  {
    if (!field.dock || !this._shouldShowDockSlideOut(field)) {
      return ''
    }
    let parts = []
    if (field.dock.getSlideOutNodes) {
      let nodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
      if (Array.isArray(nodes) && nodes.some((node) => node != null)) {
        parts.push('nodes')
      }
    }
    for (let childField of this._getDockChildFields(field.key)) {
      if (this._evaluateDockInclude(childField)) {
        parts.push(childField.key)
      }
    }
    return parts.join('|')
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _evaluateDockInclude(field)
  {
    let included = true
    if (field.dock?.include !== undefined) {
      if (typeof field.dock.include === 'function') {
        if (this._dockIncludeContext) {
          included = !!field.dock.include.call(this._dockIncludeContext)
        } else {
          included = !!field.dock.include()
        }
      } else {
        included = !!field.dock.include
      }
    }
    return included
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _refreshDockRootSlot(field)
  {
    let oldNode = field.dockElement
    if (oldNode == null) {
      return
    }
    let newNode = this.createDockElement(field.key)
    if (newNode == null) {
      oldNode.remove()
      field.dockElement = null
      field._dockShowsSlideOut = false
      field._dockSlideOutChildSignature = null
      return
    }
    oldNode.replaceWith(newNode)
  }

  /**
   * @param {string} name
   * @return {boolean}
   */
  evaluateDockInclude(name)
  {
    let field = this.getField(name)
    if (!field?.dock) {
      return false
    }
    return this._evaluateDockInclude(field)
  }

  /**
   * Detaches all mounted dock button nodes from the DOM and clears element references.
   * @return {BrazenConfigurationManager}
   */
  clearDockElements()
  {
    for (let key in this._config) {
      let field = this._config[key]
      if (field.dock?.railHead === true) {
        continue
      }
      if (field.dockElement != null) {
        field.dockElement.remove()
        field.dockElement = null
        field._dockShowsSlideOut = false
        field._dockSlideOutChildSignature = null
      }
    }
    return this
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  refreshDockButtonStates()
  {
    for (let field of this.getDockRootFields()) {
      let showsSlideOut = this._computeDockShowsSlideOut(field)
      let childSignature = this._getDockSlideOutChildSignature(field)
      if (field.dockElement &&
          (field._dockShowsSlideOut !== showsSlideOut ||
              field._dockSlideOutChildSignature !== childSignature)) {
        this._refreshDockRootSlot(field)
      }
      field._dockShowsSlideOut = showsSlideOut
      field._dockSlideOutChildSignature = childSignature
    }
    for (let key in this._config) {
      let field = this._config[key]
      if (!field.dock || !field.dockElement) {
        continue
      }
      this._updateDockFieldButton(field)
    }
    return this
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _updateDockFieldButton(field)
  {
    if (!field.dockElement) {
      return
    }
    this._overlayFieldFromCache(field)
    let value = this._getEffectiveFieldValue(field)
    let icon = typeof field.dock.icon === 'function' ? field.dock.icon(value, field) : field.dock.icon
    let disabled = this._resolveDockDisabled(field, value)
    this._uiGen.updateDockFieldButton(field, {
      stateClass: disabled ? '' : this._resolveDockStateClass(field, value),
      tooltip: this._resolveDockTooltip(field, value),
      icon: typeof icon === 'string' ? icon : undefined,
      disabled: disabled,
    })
    if (field.dockElement.classList.contains('bv-dock-slot')) {
      field.dockElement.classList.toggle('bv-dock-slideout-pinned', this._isDockSlideOutPinned(field))
    }
  }

  /**
   * @param {string} configKey
   * @returns {function(*): boolean}
   */
  generateValidationCallback(configKey)
  {
    let validationCallback
    switch (this.getField(configKey).type) {
      case CONFIG_TYPE_FLAG:
      case CONFIG_TYPE_RADIOS_GROUP:
      case CONFIG_TYPE_SELECT:
        validationCallback = (value) => value
        break
      case CONFIG_TYPE_CHECKBOXES_GROUP:
        validationCallback = (valueKeys) => valueKeys.length
        break
      case CONFIG_TYPE_NUMBER:
        validationCallback = (value) => value > 0
        break
      case CONFIG_TYPE_RANGE:
        validationCallback = (range) => range.minimum > 0 || range.maximum > 0
        break
      case CONFIG_TYPE_RULESET:
        validationCallback = (rules) => {
          if (Array.isArray(rules)) {
            return rules.length > 0
          }
          if (rules && typeof rules === 'object') {
            if (Array.isArray(rules.combos)) {
              return rules.combos.length > 0
            }
            if (typeof rules.length === 'number') {
              return rules.length > 0
            }
          }
          return !!rules
        }
        break
      case CONFIG_TYPE_TEXT:
        validationCallback = (value) => value.length
        break
      default:
        throw new Error('Associated config type requires explicit validation callback definition.')
    }
    return validationCallback
  }

  /**
   * @param {string} name
   * @return {ConfigurationField|null}
   */
  getField(name)
  {
    let field = this._config[this._formatFieldKey(name)]
    if (field) {
      this._overlayFieldFromCache(field)
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  getFieldOrFail(name)
  {
    let field = this.getField(name)
    if (field) {
      return field
    }
    throw new Error('Field named "' + name + '" could not be found')
  }

  /**
   * @param {string} name
   * @returns {*}
   */
  getValue(name)
  {
    let fieldKey = this._formatFieldKey(name)
    if (this._storageReady) {
      let cached = this._getCachedSetting(fieldKey)
      if (cached) {
        return cached.value
      }
    }
    return this.getFieldOrFail(name).value
  }

  /**
   * @param {string} name
   * @returns {*}
   */
  getOptimized(name)
  {
    let fieldKey = this._formatFieldKey(name)
    if (this._storageReady) {
      let cached = this._getCachedSetting(fieldKey)
      if (cached) {
        return cached.optimized
      }
    }
    let field = this.getFieldOrFail(name)
    return field.optimized ?? this._computeFieldOptimized(fieldKey, field.value)
  }

  /**
   * @param {string} name
   * @return {boolean}
   */
  hasField(name)
  {
    return this.getField(name) !== undefined
  }

  /**
   * @param {string} legacyScriptPrefix
   * @return {BrazenConfigurationManager}
   */
  setLegacyScriptPrefix(legacyScriptPrefix)
  {
    this._legacyScriptPrefix = legacyScriptPrefix
    return this
  }

  /**
   * @param {{skipAutoSortRepair?: boolean, skipTagEntryIdRepair?: boolean}} [options]
   * @return {Promise<BrazenConfigurationManager>}
   */
  async reloadBookmarkFields(options = {})
  {
    let tasks = []
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET || !field.pageMatch) {
        continue
      }
      tasks.push(field.reload(options).then(() => field.updateUserInterface()))
    }
    await Promise.all(tasks)
    return this
  }

  /**
   * @return {Promise<BrazenConfigurationManager>}
   */
  async reloadRulesetFields()
  {
    let tasks = []
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      tasks.push(field.reload().then(() => {
        field.updateUserInterface()
      }))
    }
    await Promise.all(tasks)
    return this
  }

  /**
   * Tear down ruleset widgets on unload.
   * @return {void}
   */
  disposeRulesetFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      field.dispose?.()
    }
  }

  /**
   * Tear down bookmark widgets (ResizeObserver + pageMatch listeners) on unload.
   * @return {void}
   */
  disposeBookmarkFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET || !field.pageMatch) {
        continue
      }
      field.dispose?.()
    }
  }

  /**
   * @param {Function} handler
   * @return {BrazenConfigurationManager}
   */
  setScriptSetup(handler)
  {
    this._scriptSetupHandler = handler
    return this
  }

  /**
   * @param {{onMigrationProgress?: function(MigrationProgress): void|Promise<void>, onMigrationStatus?: function(string): void|Promise<void>}|null} [options]
   *   Optional progress callback — invoked during safety backup, peer setup wait, schema rebuild, backfill, and ruleset migration.
   * @return {Promise<BrazenConfigurationManager>}
   */
  async initialize(options = null)
  {
    this._migrationError = null
    if (!this._repos.storage.available) {
      let error = new Error('This script requires IndexedDB and cannot run without it.')
      error.code = 'IDB_UNAVAILABLE'
      throw error
    }

    let report = this._bindMigrationProgress(options?.onMigrationProgress ?? options?.onMigrationStatus ?? null)
    this._reportMigrationProgress = report

    try {
      let wipeFlagKey = this._scriptPrefix + 'pending-idb-wipe'
      let pendingWipe = false
      try {
        pendingWipe = sessionStorage.getItem(wipeFlagKey) === '1'
      } catch (e) {
        pendingWipe = false
      }
      if (pendingWipe) {
        await this._repos.storage.deleteDatabase()
        try {
          sessionStorage.removeItem(wipeFlagKey)
        } catch (e) {
        }
      }

      let schemaConflict = await this.getSchemaVersionConflict()
      if (schemaConflict) {
        let schemaError = new Error(
            'Local database schema v' + schemaConflict.installed +
            ' is newer than this script supports (v' + schemaConflict.supported + ').')
        schemaError.code = 'SCHEMA_TOO_NEW'
        schemaError.schemaConflict = schemaConflict
        throw schemaError
      }

      await this._repos.storage.open()
      let existingMeta = await this._repos.meta.get()
      let migrationNeeded = await this._isMigrationWorkNeeded(existingMeta)

      if (migrationNeeded) {
        let prevCounts = existingMeta?.migrationSafetyBackupCounts
        let backupWasEmpty = prevCounts &&
            !prevCounts.tags && !prevCounts.bookmarks && !prevCounts.ledgerEntries
        let skipBackup = !backupWasEmpty &&
            existingMeta?.migrationSafetyBackupRevisionId === existingMeta?.revisionId &&
            existingMeta?.migrationSafetyBackupAt
        if (skipBackup) {
          let hint = this.getPreMigrationBackupFilenameHint() ??
              this._scriptPrefix + 'pre-migration-backup.zip'
          await report({
            phase: 'safety-backup',
            label: 'Safety backup already saved',
            detail: hint,
            current: 1,
            total: 1,
          })
        } else {
          await report({phase: 'safety-backup', label: 'Creating safety backup…', current: 0, total: 1})
          await this.createPreMigrationSafetyBackup(report)
          await report({phase: 'safety-backup', label: 'Safety backup saved', current: 1, total: 1})
        }
      }

      if (existingMeta?.setupInProgress) {
        await this._waitPeerSetupWithProgress(report)
      }

      let healthy = await this._repos.storage.isHealthy()
      if (!healthy) {
        await report({phase: 'schema-rebuild', label: 'Rebuilding database schema…', indeterminate: true})
        await this._repos.storage.deleteDatabase()
        await this._repos.storage.open()
        await this._runSetupPhase(report)
      } else {
        await this._repos.storage.runPendingIsDiscoveredBackfill(report)
        if (typeof migrateRulesetConfigDefaultsV9 === 'function') {
          await migrateRulesetConfigDefaultsV9(this._repos, report)
        }
        if (typeof migrateRulesetHideTagTypesV10 === 'function') {
          await migrateRulesetHideTagTypesV10(this._repos, report)
        }
        let meta = await this._repos.meta.get()
        if (!meta?.rulesetMigrated) {
          await this._runPostOpenRulesetMigration(report)
        }
        meta = await this._repos.meta.get()
        if (!meta?.bookmarksMigrated && typeof migrateBookmarksToRuleset === 'function') {
          await migrateBookmarksToRuleset(this._repos, report)
        }
        await this._seedAllFields()
      }
      await this._runNormalPhase()
      if (!(await this._repos.storage.isHealthy())) {
        throw new Error('Database health check failed after migration')
      }
    } catch (error) {
      console.log('[BrazenCM] initialize failed:', error)
      this._migrationError = error
      throw error
    } finally {
      this._reportMigrationProgress = null
    }

    return this
  }

  /**
   * @param {function(MigrationProgress|string): Promise<void>} report
   * @return {Promise<void>}
   * @private
   */
  async _runPostOpenRulesetMigration(report)
  {
    if (this._scriptSetupHandler) {
      await report({phase: 'script-seed', label: 'Applying site configuration…', indeterminate: true})
      await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
      return
    }
    if (typeof migrateRulesetFromLegacy === 'function') {
      await migrateRulesetFromLegacy(this._repos, this, report)
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _runSetupPhase(report = null)
  {
    let progress = report ?? (async () => {})
    await progress({phase: 'schema-rebuild', label: 'Initializing database…', current: 0, total: 1})
    await this._repos.meta.beginSetup()
    await this._repos.storage.put(IDB_STORE_META, await this._repos.storage.createDefaultMeta())
    await this._repos.storage.put(IDB_STORE_SETTINGS, await this._repos.storage.createEmptySettingsDocument())
    await this._repos.storage.put(IDB_STORE_APIS, await this._repos.storage.createEmptyApisDocument())
    await this._repos.storage.put(IDB_STORE_TAG_TYPES, await this._repos.storage.createEmptyTagTypesDocument())

    let sources = []
    if (this._legacyDataExists()) {
      await progress({phase: 'legacy-import', label: 'Importing legacy settings…', indeterminate: true})
      let importer = new BrazenLegacyImporter(this._repos, this)
      sources = await importer.run()
    }

    if (this._scriptSetupHandler) {
      await progress({phase: 'script-seed', label: 'Applying site configuration…', indeterminate: true})
      await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
    }

    await this._seedAllFields()
    await this._importTagDomainFromLegacySettings()
    await this._recompileAllTagRuleSets(progress)
    await this._repos.meta.completeSetup(sources)
    this._storageReady = true
    await this._syncFieldsFromIdb()
    await this._repos.tagRuntime.warmCache()
    this._bootFieldsHydrated = true
  }

  /**
   * Open IDB + migration only — ruleset compile, TagRuntime warm, and field reload defer to
   * {@link hydrateBootFieldsFromStorage} after the Framework dock paints.
   * @return {Promise<void>}
   * @private
   */
  async _runNormalPhaseEssentials()
  {
    await this._repos.meta.waitForSetupComplete()
    this._storageReady = true
    this._ensureConfigReactor()
    let meta = await this._repos.meta.get()
    this._applySyncedDomainRevisionCursors(meta)
    await this._refreshScalarSettingCacheFromIdb()

    if (this._idbVisibilityHandler) {
      document.removeEventListener('visibilitychange', this._idbVisibilityHandler)
    }
    this._idbVisibilityHandler = () => {
      if (document.hidden) {
        return
      }
      if (this._visibilityWakeDelegate) {
        this._visibilityWakeDelegate()
        return
      }
      void this.syncFromForeignRevisionIfNeeded()
    }
    document.addEventListener('visibilitychange', this._idbVisibilityHandler)
    // bfcache restore may not flip visibility; same foreign-revision check as focus.
    if (this._idbPageshowHandler) {
      window.removeEventListener('pageshow', this._idbPageshowHandler)
    }
    this._idbPageshowHandler = (event) => {
      let persisted = !!event.persisted
      if (persisted || document.visibilityState === 'visible') {
        if (this._visibilityWakeDelegate) {
          this._visibilityWakeDelegate()
          return
        }
        void this.syncFromForeignRevisionIfNeeded()
      }
    }
    window.addEventListener('pageshow', this._idbPageshowHandler)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _runNormalPhase()
  {
    await this._runNormalPhaseEssentials()
  }

  /**
   * Full ruleset compile, TagRuntime warm, and ruleset/ledger reload — call after UI embed.
   * No-op when setup/migration already hydrated during {@link initialize}.
   * @return {Promise<BrazenConfigurationManager>}
   */
  async hydrateBootFieldsFromStorage()
  {
    if (this._bootFieldsHydrated) {
      return this
    }
    if (!this._storageReady) {
      await this.initialize()
      return this
    }
    await this._syncFieldsFromIdb()
    await this._repos.tagRuntime.warmCache()

    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_LEDGER) {
        await field.reload()
      }
      if (field.type === CONFIG_TYPE_RULESET) {
        await field.reload()
      }
    }

    let meta = await this._repos.meta.get()
    this._bootRulesetCompiledConfigSeq = meta?.domainConfigSeq ?? 0
    this._bootFieldsHydrated = true
    return this
  }

  /**
   * Hydrate only the fields needed for search-default URL injection — before full ruleset
   * hydrate / Download Manager init so list pages can redirect without waiting on queue boot.
   *
   * @param {string[]} fieldKeys
   * @return {Promise<BrazenConfigurationManager>}
   */
  async hydrateSearchDefaultsFromStorage(fieldKeys)
  {
    if (!Array.isArray(fieldKeys) || !fieldKeys.length) {
      return this
    }
    if (this._bootFieldsHydrated) {
      return this
    }
    if (!this._storageReady) {
      await this.initialize()
      return this
    }
    for (let fieldKey of fieldKeys) {
      await this._syncFieldFromIdb(fieldKey)
    }
    return this
  }

  /**
   * @param {object|null|undefined} meta
   * @return {{configChanged: boolean, tagsChanged: boolean, ledgerChanged: boolean}}
   * @private
   */
  _getForeignDomainRevisionDelta(meta)
  {
    let configSeq = meta?.domainConfigSeq ?? 0
    let tagsSeq = meta?.domainTagsSeq ?? 0
    let ledgerSeq = meta?.domainLedgerSeq ?? 0
    return {
      configChanged: configSeq !== (this._syncedDomainConfigSeq ?? 0),
      tagsChanged: tagsSeq !== (this._syncedDomainTagsSeq ?? 0),
      ledgerChanged: ledgerSeq !== (this._syncedDomainLedgerSeq ?? 0),
    }
  }

  /**
   * @param {object|null|undefined} meta
   * @private
   */
  _applySyncedDomainRevisionCursors(meta)
  {
    this._syncedRevisionId = meta?.revisionId ?? null
    this._syncedDomainConfigSeq = meta?.domainConfigSeq ?? 0
    this._syncedDomainTagsSeq = meta?.domainTagsSeq ?? 0
    this._syncedDomainLedgerSeq = meta?.domainLedgerSeq ?? 0
  }

  /**
   * Reload settings / tags from IDB when another tab advanced config or tag domain seqs.
   * Clears TagRuntime RAM so sidebar / filters cannot keep stale attribute rows
   * (`ensureNames` skips names already cached).
   * @param {number|string} revisionId
   * @return {Promise<void>}
   * @private
   */
  async _syncForeignConfigAndTagsFromIdb(revisionId)
  {
    this._applyRevisionBumpSignals(revisionId, {})
    this._settingCacheWriteKeys.clear()
    this._repos.tagRuntime?.clearCache()
    this._repos.rulesetFields.invalidateCompiledCache()
    await this._syncFieldsFromIdb()
    await this._repos.tagRuntime.warmCache()
    let foreignReloadOptions = {skipAutoSortRepair: true, skipTagEntryIdRepair: true}
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET) {
        await field.reload(foreignReloadOptions)
        field.updateUserInterface()
      }
    }
    await this.refreshTagComplianceSpecs(['tag-blacklist', 'explored-tags-tracker'])
    let syncedMeta = await this._repos.meta.get()
    this._bootRulesetCompiledConfigSeq = syncedMeta?.domainConfigSeq ?? 0
    this.notifyConfigurationChange('foreign-ruleset-sync', false, {skipMountedRefresh: true})
  }

  /**
   * Reload ledger positive caches when only the ledger domain seq advanced.
   * @param {number|string} revisionId
   * @return {Promise<void>}
   * @private
   */
  async _syncForeignLedgerFromIdb(revisionId)
  {
    this._applyRevisionBumpSignals(revisionId, {source: 'ledger'})
    await this._syncLedgerFieldsFromIdb()
    this.notifyConfigurationChange('ledger', false)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncLedgerFieldsFromIdb()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_LEDGER && typeof field.reload === 'function') {
        await field.reload()
      }
    }
  }

  /**
   * @param {Function|null} delegate
   * @return {BrazenConfigurationManager}
   */
  setVisibilityWakeDelegate(delegate)
  {
    this._visibilityWakeDelegate = typeof delegate === 'function' ? delegate : null
    return this
  }

  /**
   * Cross-tab / visibility foreign revision sync (in-flight coalesced).
   * @return {Promise<void>}
   */
  syncFromForeignRevisionIfNeeded()
  {
    if (this._foreignSyncInFlight) {
      return this._foreignSyncInFlight
    }
    this._foreignSyncInFlight = this._syncFromForeignRevisionIfNeeded().
        finally(() => {
          this._foreignSyncInFlight = null
        })
    return this._foreignSyncInFlight
  }

  /**
   * Reload settings / tags from IDB when another tab advanced domain seq counters.
   * @return {Promise<void>}
   * @private
   */
  async _syncFromForeignRevisionIfNeeded()
  {
    if (!this._storageReady) {
      return
    }
    let meta = await this._repos.meta.get()
    if (!meta) {
      return
    }
    let domainDelta = this._getForeignDomainRevisionDelta(meta)
    if (!domainDelta.configChanged && !domainDelta.tagsChanged && !domainDelta.ledgerChanged) {
      return
    }
    this._applySyncedDomainRevisionCursors(meta)
    if (domainDelta.configChanged || domainDelta.tagsChanged) {
      await this._syncForeignConfigAndTagsFromIdb(meta.revisionId)
      return
    }
    await this._syncForeignLedgerFromIdb(meta.revisionId)
  }

  /**
   * @return {boolean}
   * @private
   */
  _legacyDataExists()
  {
    return legacyStorageHasData(this._scriptPrefix, this._legacyScriptPrefix)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncFieldsFromIdb()
  {
    await this._refreshSettingCacheFromIdb()
    this._settingCacheWriteKeys.clear()
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<void>}
   * @private
   */
  async _syncFieldFromIdb(fieldKey)
  {
    let field = this._config[fieldKey]
    if (!field) {
      return
    }
    if (field.type === CONFIG_TYPE_RULESET) {
      let compiled = await this._compileRulesetFieldFromIdb(fieldKey)
      this._cacheSetting(fieldKey, compiled?.rawLines ?? [], compiled?.optimized ?? null)
      if (field.reload) {
        await field.reload()
      }
      this._overlayFieldFromCache(field)
      return
    }
    if (field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
        field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
        field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
      let stored = await this._repos.settings.getField(fieldKey)
      if (stored) {
        this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
      }
    }
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @return {*}
   * @private
   */
  _computeFieldOptimized(fieldKey, value)
  {
    let field = this._config[fieldKey]
    if (!field) {
      return null
    }
    return Utilities.callEventHandler(field.onOptimize, [value], value)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _importTagDomainFromLegacySettings()
  {
    let aggregate = readLegacySettingsAggregate(this._scriptPrefix)
    if (!aggregate || typeof aggregate !== 'object') {
      return
    }
    for (let spec of RULESET_MIGRATION_FIELD_SPECS) {
      if (aggregate[spec.fieldKey] === undefined) {
        continue
      }
      let value = aggregate[spec.fieldKey]
      if (Array.isArray(value) && value.length === 0) {
        continue
      }
      await this._importTagFieldFromValue(spec.fieldKey, value)
    }
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @return {Promise<void>}
   * @private
   */
  async _importTagFieldFromValue(fieldKey, value, options = {})
  {
    let field = this._config[fieldKey]
    if (!field) {
      return
    }
    let spec = RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === fieldKey)
    if (!spec) {
      return
    }
    let seed = this._fieldSeeds.get(fieldKey)
    let existingMain = await this._repos.rulesetFields.get(fieldKey)
    let backupConfig = options.config ??
        (value && typeof value === 'object' && !Array.isArray(value) ? value.config : null)
    let importConfig
    if (backupConfig && typeof backupConfig === 'object') {
      importConfig = backupConfig
    } else if (existingMain?.config && Object.keys(existingMain.config).length) {
      importConfig = existingMain.config
    } else {
      importConfig = {...DEFAULT_RULESET_USER_CONFIG, ...(seed?.config ?? spec.config ?? {})}
    }
    await this._repos.rulesetFields.upsert({
      fieldKey: spec.fieldKey,
      templateId: spec.templateId,
      templateConfig: spec.templateConfig,
      config: importConfig,
    })
    field.templateId = spec.templateId
    field.templateConfig = spec.templateConfig
    field._rulesetMain = await this._repos.rulesetFields.get(fieldKey)

    let lines = []
    let lineSource = Array.isArray(value) ? value : (value?.lines ?? value?.entries ?? [])
    if (Array.isArray(lineSource)) {
      if (lineSource.length && typeof lineSource[0] === 'object' && lineSource[0]?.subject) {
        for (let entry of lineSource) {
          if (entry?.subject) {
            lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
          }
        }
      } else {
        lines = lineSource.map(String)
      }
    }
    let expanded = []
    for (let line of lines) {
      if (line.includes('|')) {
        expanded.push(...expandOrRuleLine(line))
      } else {
        expanded.push(line)
      }
    }
    if (!expanded.length) {
      return
    }

    let existing = await this._repos.rulesetEntries.listAllForField(fieldKey)
    for (let row of existing) {
      await this._repos.rulesetEntries.remove(row.entryId)
    }

    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(spec.templateId) : null
    if (!template) {
      return
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    let sortOrder = 0
    for (let rawLine of expanded) {
      let parsed = template.parseImportLine?.(String(rawLine), ctx)
      if (!parsed) {
        continue
      }
      await template.persist(parsed.payload, ctx, {
        sortOrder: sortOrder++,
        comment: parsed.comment ?? '',
      })
    }
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, fieldKey)
    }
    await this._syncFieldFromIdb(fieldKey)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _recompileAllTagRuleSets(report = null)
  {
    await this.ensureRulesetFieldsCompiled(null, report)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _ensureStorageReady()
  {
    if (!this._storageReady) {
      await this.initialize()
    }
  }

  /**
   * @return {boolean}
   */
  isStorageReady()
  {
    return this._storageReady
  }

  /**
   * @return {boolean}
   */
  isIndexedDBAvailable()
  {
    return this._repos.storage.available
  }

  /**
   * Count post ids in the download duplicate ledger (IndexedDB row count; no full-table RAM load).
   * @return {Promise<number>}
   */
  async countDownloadLedger()
  {
    if (!this.getField('download-ledger')) {
      return 0
    }
    await this._ensureStorageReady()
    return this._repos.ledger.countLedgerEntries()
  }

  /**
   * Wipe the download duplicate ledger (all `ledgerEntries` rows) and reload the in-memory Set.
   * @return {Promise<void>}
   */
  async clearDownloadLedger()
  {
    await this._ensureStorageReady()
    await this._repos.ledger.replaceAll([])
    let field = this.getField('download-ledger')
    if (field?.reload) {
      await field.reload()
    }
  }

  /**
   * Replace the download duplicate ledger with the supplied post ids (atomic wipe + write).
   * @param {Iterable<string|number|null|undefined>} ids
   * @return {Promise<number>} count of ids written
   */
  async replaceDownloadLedgerIds(ids)
  {
    await this._ensureStorageReady()
    let field = this.getField('download-ledger')
    let isValidId = field?._ledgerIsValidId?.() ??
        ((value) => typeof value === 'string' && value.trim().length > 0)
    let written = 0
    await this.beginDownloadLedgerFolderImport(true)
    let batch = []
    for (let raw of ids) {
      if (raw === null || raw === undefined || !String(raw).length) {
        continue
      }
      let postId = String(raw).trim()
      if (!postId || !isValidId(postId)) {
        continue
      }
      batch.push(postId)
      if (batch.length >= LEDGER_FOLDER_IMPORT_BATCH_SIZE) {
        written += await this.mergeDownloadLedgerImportBatch(batch)
        batch = []
      }
    }
    if (batch.length) {
      written += await this.mergeDownloadLedgerImportBatch(batch)
    }
    await this.finalizeDownloadLedgerFolderImport()
    return written
  }

  /**
   * Prepare ledger folder import: optionally wipe the ledger (replace mode).
   * Pair with {@link mergeDownloadLedgerImportBatch} and {@link finalizeDownloadLedgerFolderImport}.
   *
   * @param {boolean} [replace=false]
   * @return {Promise<boolean>}
   */
  async beginDownloadLedgerFolderImport(replace = false)
  {
    await this._ensureStorageReady()
    if (replace) {
      await this._repos.ledger.replaceAll([])
      let field = this.getField('download-ledger')
      if (field?.reload) {
        await field.reload()
      }
    }
    this._ledgerImportRevisionPending = true
    return true
  }

  /**
   * Upsert one batch of post ids during folder import (no full-table RAM mirror).
   *
   * @param {Iterable<string|number|null|undefined>} ids
   * @return {Promise<number>} rows accepted for write
   */
  async mergeDownloadLedgerImportBatch(ids)
  {
    await this._ensureStorageReady()
    let field = this.getField('download-ledger')
    let isValidId = field?._ledgerIsValidId?.() ??
        ((value) => typeof value === 'string' && value.trim().length > 0)
    let claimedAt = Date.now()
    /** @type {{postId: string, claimedAt: number}[]} */
    let rows = []
    for (let raw of ids) {
      if (raw === null || raw === undefined || !String(raw).length) {
        continue
      }
      let postId = String(raw).trim()
      if (!postId || !isValidId(postId)) {
        continue
      }
      rows.push({postId, claimedAt})
    }
    if (!rows.length) {
      return 0
    }
    await this._repos.ledger.mergeRows(rows, {deferRevision: true})
    return rows.length
  }

  /**
   * Commit a deferred folder import (single revision bump + cache reload).
   * @return {Promise<void>}
   */
  async finalizeDownloadLedgerFolderImport()
  {
    if (this._ledgerImportRevisionPending) {
      await this._repos.ledger.commitMergeRows()
      this._ledgerImportRevisionPending = false
    }
    let field = this.getField('download-ledger')
    if (field?.reload) {
      await field.reload()
    }
  }

  /**
   * Wipe this script's entire IndexedDB database (settings, bookmarks, ledger, tags,
   * download queues, etc.). Caller should reload the page afterward so setup can run again.
   * @return {Promise<void>}
   */
  async clearScriptDatabase()
  {
    if (!this._repos?.storage) {
      return
    }
    this._storageReady = false
    await this._repos.storage.deleteDatabase()
  }

  /**
   * 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}>}
   */
  async resetAllTagsDiscovered(onProgress = null)
  {
    if (!this._repos?.tags) {
      return {updated: 0, total: 0}
    }
    let result = await this._repos.tags.resetAllTagsDiscovered(onProgress)
    this._repos.tagRuntime?.clearCache()
    await this._commitTagFieldChange()
    return result
  }

  /**
   * Cached compliance specs keyed by field (`combos`).
   * @type {Record<string, {combos: {tagEntryIds: number[]}[]}>}
   * @private
   */
  _tagComplianceSpecs = {}

  /**
   * @return {boolean}
   */
  canPersist()
  {
    return this._storageReady
  }

  /**
   * Read a settings field from IndexedDB (source of truth via `_settingCache`).
   * @param {string} fieldKey
   * @return {Promise<*>}
   */
  async readSetting(fieldKey)
  {
    await this._ensureStorageReady()
    let stored = await this._repos.settings.getField(fieldKey)
    if (!stored) {
      return this._getCachedSetting(fieldKey)?.value ?? null
    }
    this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
    return stored.value
  }

  /**
   * Write a settings field directly to IndexedDB (headless / dock-only fields). `save()` only persists mounted panel fields.
   * @param {string} fieldKey
   * @param {*} value
   * @return {Promise<BrazenConfigurationManager>}
   */
  async writeSetting(fieldKey, value)
  {
    await this._ensureStorageReady()
    let field = this.getFieldOrFail(fieldKey)
    let optimized = Utilities.callEventHandler(field.onOptimize, [value], value)
    if (typeof this._commandSender === 'function') {
      await this._commandSender({type: 'write-setting', payload: {fieldKey, value, optimized}})
      this._cacheSetting(fieldKey, value, optimized, true)
      this._overlayFieldFromCache(field)
      this.notifyConfigurationChange('settings', true)
      return this
    }
    await this._repos.settings.putField(fieldKey, value, optimized)
    this._cacheSetting(fieldKey, value, optimized, true)
    this._overlayFieldFromCache(field)
    await this._repos.meta.bumpRevision()
    this.notifyConfigurationChange('settings', true)
    return this
  }

  /**
   * @return {BrazenStorageRepositories}
   */
  getRepos()
  {
    return this._repos
  }

  /**
   * @return {TagRuntime|null}
   */
  getTagRuntime()
  {
    return this._repos?.tagRuntime ?? null
  }

  /**
   * @param {string} fieldKey
   * @return {boolean}
   */
  isTagRegistryField(fieldKey)
  {
    let field = this.getField(fieldKey)
    if (field?.type !== CONFIG_TYPE_RULESET) {
      return false
    }
    let templateId = field.templateId ?? field._rulesetMain?.templateId
    return templateId === 'tag-blacklist' ||
        templateId === 'explored-tags' ||
        templateId === 'tag-sole-ignore' ||
        templateId === 'substitution'
  }

  /**
   * @param {string} tagName Normalized tag name.
   * @return {*|null}
   */
  getCachedTagEntry(tagName)
  {
    return this.getTagRuntime()?.resolveCachedByName(tagName) ?? null
  }

  /**
   * Filename-ignore sole attribute — same row lookup as download join / Aa button.
   * @param {string} tagName
   * @return {boolean}
   */
  isFilenameTagIgnored(tagName)
  {
    return this.hasTagSoleAttribute('filename-tag-ignore-list', tagName)
  }

  /**
   * Sync filename-ignore `field.value` during optimistic toggle (bookmark-parity read source).
   * @param {ConfigurationField} field
   * @param {string} normalized
   * @param {boolean} present
   * @private
   */
  _syncOptimisticIgnoreValue(field, normalized, present)
  {
    if (!field || field.key !== 'filename-tag-ignore-list') {
      return
    }
    let lines = Array.isArray(field.value) ? field.value.map(String) : []
    if (present) {
      let names = new Set(lines.map((line) => String(line ?? '').trim()).filter(Boolean))
      if (!names.has(normalized)) {
        field.value = [...lines, normalized]
      }
    } else {
      // Exact removal only — mirror the exact membership test in _compiledIgnoreHasTagName. Series
      // stripping must not remove sibling `_(variant)` rules when toggling off a bare tag.
      field.value = lines.filter((line) => {
        let name = String(line ?? '').trim()
        return !!name && name !== normalized
      })
    }
    this._cacheSetting(field.key, field.value, field.optimized)
  }

  /**
   * Filename-ignore read against compiled raw lines (full field, not paginated `_rulesetRows`).
   * @param {ConfigurationField} field
   * @param {string} normalized
   * @param {string} fieldKey
   * @return {boolean}
   * @private
   */
  _compiledIgnoreHasTagName(field, normalized, fieldKey)
  {
    if (fieldKey === 'filename-tag-ignore-list') {
      let optimizedIds = field?.optimized?.tagEntryIds
      if (Array.isArray(optimizedIds) && optimizedIds.length) {
        let idSet = new Set(optimizedIds)
        for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
          if (idSet.has(entryId)) {
            return true
          }
        }
      }
    }
    let lines = field?.value
    if (!Array.isArray(lines) || !lines.length) {
      return false
    }
    let names = new Set(lines.map((line) => String(line ?? '').trim()).filter(Boolean))
    if (names.has(normalized)) {
      return true
    }
    let normalizedLower = normalized.toLowerCase()
    for (let name of names) {
      if (name.toLowerCase() === normalizedLower) {
        return true
      }
    }
    if (fieldKey !== 'filename-tag-ignore-list') {
      return false
    }
    // Forward series coverage only: ignoring a base character tag (`the_twins`) covers its
    // `_(series)` variants (`the_twins_(atomic_heart)`) — the base rule is the backing row.
    // The reverse coupling (a specific ignored variant making the BARE base report active) is a
    // bug: it reports the base as ignored with no backing row, so the toggle-off is a no-op and
    // the icon reverts forever. Series stripping in the other direction is a download-time concern
    // (`strip-series-from-character-tags`, applied in applyDownloadAttributesSync), not a rule read.
    let stripped = normalized.replace(/_\([^)]*\)$/, '')
    if (stripped === normalized) {
      return false
    }
    if (names.has(stripped)) {
      return true
    }
    let strippedLower = stripped.toLowerCase()
    for (let name of names) {
      if (name.toLowerCase() === strippedLower) {
        return true
      }
    }
    return false
  }

  /**
   * Entry ids to consult for filename-ignore reads. Exact tag identity only — series-base
   * coupling belongs to the download-time `strip-series-from-character-tags` option, not rule reads.
   * @param {string} normalized
   * @param {string} [fieldKey]
   * @return {number[]}
   * @private
   */
  _ignoreCandidateEntryIds(normalized, fieldKey)
  {
    let entryIds = []
    let seen = new Set()
    let add = (name) => {
      if (!name) {
        return
      }
      let cached = this.getCachedTagEntry(name)
      if (cached?.entryId != null && !seen.has(cached.entryId)) {
        seen.add(cached.entryId)
        entryIds.push(cached.entryId)
      }
    }
    add(normalized)
    return entryIds
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {string} normalized
   * @param {string} fieldKey
   * @return {boolean}
   * @private
   */
  _hasTagSoleAttributeByEntryId(field, templateId, normalized, fieldKey)
  {
    if (templateId === 'tag-sole-ignore') {
      return this._compiledIgnoreHasTagName(field, normalized, fieldKey)
    }
    for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
      let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
          {name: normalized, entryId}
      if (this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))) {
        return true
      }
    }
    let cached = this.getCachedTagEntry(normalized)
    if (cached?.entryId != null) {
      if (this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))) {
        return true
      }
    }
    if (this._rulesetMatchRequiresResolvedTagEntry(templateId)) {
      return !!this._findRulesetRowForTarget(field, {
        variant: 'sole',
        tagName: normalized,
        tagEntryId: null,
      })
    }
    return !!this._findRulesetRowForTarget(
        field,
        this._rulesetMatchTargetForTag(templateId, normalized))
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {string} normalized
   * @return {*|null}
   * @private
   */
  _findSoleAttributeRowForToggle(field, templateId, normalized)
  {
    for (let entryId of this._ignoreCandidateEntryIds(normalized, field.key)) {
      let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
          {name: normalized, entryId}
      let row = this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))
      if (row) {
        return row
      }
    }
    let cached = this.getCachedTagEntry(normalized)
    if (cached?.entryId != null) {
      return this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))
    }
    if (this._rulesetMatchRequiresResolvedTagEntry(templateId)) {
      return this._findRulesetRowForTarget(field, {
        variant: 'sole',
        tagName: normalized,
        tagEntryId: null,
      })
    }
    return this._findRulesetRowForTarget(field, this._rulesetMatchTargetForTag(templateId, normalized))
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName Normalized tag name.
   * @return {boolean}
   */
  hasTagSoleAttribute(fieldKey, tagName)
  {
    let normalized = String(tagName ?? '').trim()
    if (!normalized) {
      return false
    }
    let field = this.getField(fieldKey)
    if (field?.type === CONFIG_TYPE_RULESET) {
      let templateId = field.templateId ?? field._rulesetMain?.templateId
      if (templateId) {
        if (this._rulesetTemplateRequiresTagEntryId(templateId)) {
          return this._hasTagSoleAttributeByEntryId(field, templateId, normalized, fieldKey)
        }
        let matchTarget = this._rulesetMatchTargetForTag(templateId, normalized)
        return !!this._findRulesetRowForTarget(field, matchTarget)
      }
    }
    return field?.hasRule?.(normalized) ?? false
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} subjectName Normalized subject tag name.
   * @return {*|null}
   * @private
   */
  _findSubstitutionSubjectRow(field, subjectName)
  {
    let normalized = String(subjectName ?? '').trim()
    if (!normalized || !field) {
      return null
    }
    let cached = this.getCachedTagEntry(normalized)
    let cachedEntryId = cached?.entryId ?? null
    let cachedName = cached?.name ?? null
    for (let row of field._rulesetRows ?? []) {
      let payload = row.payload ?? {}
      if (payload.subjectName === normalized || (cachedName && payload.subjectName === cachedName)) {
        return row
      }
      if (cachedEntryId != null && payload.subjectTagEntryId === cachedEntryId) {
        return row
      }
    }
    return null
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} subjectName Normalized subject tag name.
   * @return {boolean}
   * @private
   */
  _compiledSubstitutionHasSubject(field, subjectName)
  {
    let optimized = field?.optimized
    if (!Array.isArray(optimized)) {
      return false
    }
    let cached = this.getCachedTagEntry(subjectName)
    let cachedEntryId = cached?.entryId ?? null
    let cachedName = cached?.name ?? null
    for (let row of optimized) {
      if (cachedEntryId != null && row?.subjectTagEntryId === cachedEntryId) {
        return true
      }
      if (row?.subjectName === subjectName || (cachedName && row?.subjectName === cachedName)) {
        return true
      }
    }
    return false
  }

  /**
   * @param {string} tagName Normalized tag name.
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @return {boolean}
   */
  hasTagSubstitutionSubject(tagName, fieldKey = 'filename-tag-substitutions')
  {
    let normalized = String(tagName ?? '').trim()
    if (!normalized) {
      return false
    }
    let field = this.getField(fieldKey)
    if (field?.type === CONFIG_TYPE_RULESET && (field.templateId === 'substitution' || field._rulesetMain?.templateId === 'substitution')) {
      if (this._compiledSubstitutionHasSubject(field, normalized)) {
        return true
      }
      return !!this._findSubstitutionSubjectRow(field, normalized)
    }
    return field?.hasRule?.(normalized) ?? false
  }

  /**
   * Persist a filename-tag substitution from the Subject → Alias composer.
   * @param {ConfigurationField} field
   * @param {string} subject
   * @param {string} alias
   * @return {Promise<boolean>}
   * @private
   */
  async _addSubstitutionFromComposer(field, subject, alias)
  {
    let normalize = field.substitutionComposer?.normalize
    let subjectName = normalize
        ? Utilities.callEventHandler(normalize, [subject], String(subject ?? '').trim())
        : String(subject ?? '').trim()
    let aliasName = normalize
        ? Utilities.callEventHandler(normalize, [alias], String(alias ?? '').trim())
        : String(alias ?? '').trim()
    if (!subjectName || !aliasName || subjectName === aliasName) {
      return false
    }
    return this.setTagSubstitution(subjectName, aliasName, field.key)
  }

  /**
   * Persist a filename-tag substitution (`subject` → `replacement`). Names should already be normalized by the caller.
   * @param {string} subject
   * @param {string} replacement
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @param {{subjectTypeName?: string|null, replacementTypeName?: string|null, source?: string|null}} [typeContext]
   * @return {Promise<boolean>}
   */
  async setTagSubstitution(subject, replacement, fieldKey = 'filename-tag-substitutions', typeContext = null)
  {
    let field = this.getField(fieldKey)
    if (!field) {
      return false
    }
    let subjectName = String(subject ?? '').trim()
    let replacementName = String(replacement ?? '').trim()
    if (!subjectName || !replacementName) {
      return false
    }
    let main = await this._repos.rulesetFields.get(fieldKey)
    if (main?.templateId === 'substitution' && typeof RulesetTemplateRegistry !== 'undefined') {
      let template = RulesetTemplateRegistry.get('substitution')
      let ctx = this._buildRulesetTemplateCtx(field)
      let existing = this._findSubstitutionSubjectRow(field, subjectName)
      if (!existing) {
        let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
        existing = rows.find((row) => template.matchRule(row.payload, {subjectName})) ?? null
      }
      if (existing?.entryId != null) {
        await this._repos.rulesetEntries.remove(existing.entryId)
        field.removeRow(existing.entryId)
      }
      let subjectCtx = this.createTagContext({
        typeName: typeContext?.subjectTypeName ?? null,
        replacementName,
        source: typeContext?.source ?? 'tag-action',
      })
      await this._repos.tagRuntime.ensureTag(subjectName, subjectCtx)
      if (typeContext?.replacementTypeName) {
        await this._repos.tagRuntime.ensureTag(replacementName, this.createTagContext({
          typeName: typeContext.replacementTypeName,
          source: typeContext?.source ?? 'tag-action',
        }))
      }
      let payload = {subjectName, replacementName}
      let row = await template.persist(payload, ctx)
      field.patchRow(row)
      if (typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, fieldKey)
      }
      await field.getOptimized()
      field.updateUserInterface?.()
      this._refreshRulesetFieldUiIfClean(field)
      // Parity with other ruleset mutations: repaint the settings panel widget and any
      // mounted mirror (sidebar / discovery) without waiting on the full config-change pass.
      this.notifyRulesetMutation({
        fieldKeys: [fieldKey],
        entryIds: row?.entryId != null ? [row.entryId] : [],
        tagNames: [subjectName, replacementName],
      })
      await this._commitTagFieldChange(this.createTagsChangeDetail([subjectName, replacementName], fieldKey))
      return true
    }
    return false
  }

  /**
   * Remove a filename-tag substitution subject. Name should already be normalized by the caller.
   * @param {string} subject
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @return {Promise<boolean>}
   */
  async clearTagSubstitution(subject, fieldKey = 'filename-tag-substitutions')
  {
    let field = this.getField(fieldKey)
    if (!field) {
      return false
    }
    let subjectName = String(subject ?? '').trim()
    if (!subjectName) {
      return false
    }
    let main = await this._repos.rulesetFields.get(fieldKey)
    if (main?.templateId === 'substitution' && typeof RulesetTemplateRegistry !== 'undefined') {
      let template = RulesetTemplateRegistry.get('substitution')
      let matchTarget = {subjectName}
      let existing = this._findSubstitutionSubjectRow(field, subjectName)
      if (!existing) {
        let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
        existing = rows.find((row) => template.matchRule(row.payload, matchTarget)) ?? null
      }
      let removedEntryId = null
      if (existing?.entryId != null) {
        removedEntryId = existing.entryId
        await this._repos.rulesetEntries.remove(existing.entryId)
        field.removeRow(existing.entryId)
      }
      if (typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, fieldKey)
      }
      await field.getOptimized()
      field.updateUserInterface?.()
      this._refreshRulesetFieldUiIfClean(field)
      // Parity with other ruleset mutations: repaint mounted panel / mirrors immediately.
      this.notifyRulesetMutation({
        fieldKeys: [fieldKey],
        entryIds: removedEntryId != null ? [removedEntryId] : [],
        tagNames: [subjectName],
      })
      await this._commitTagFieldChange(this.createTagsChangeDetail(subjectName, fieldKey))
      return true
    }
    return false
  }

  /**
   * @param {string} fieldKey
   * @return {boolean}
   */
  hasTagComplianceRules(fieldKey)
  {
    let spec = this.getTagComplianceSpec(fieldKey)
    if (spec?.combos?.length) {
      return true
    }
    let field = this.getField(fieldKey)
    if (field?.optimized?.combos?.length) {
      return true
    }
    let cached = this._getCachedSetting(fieldKey)
    return !!(cached?.optimized?.combos?.length)
  }

  /**
   * @param {string[]} [fieldKeys] Compliance ruleset fields.
   * @return {Promise<void>}
   */
  async refreshTagComplianceSpecs(fieldKeys = null)
  {
    let tagRuntime = this.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    let keys = fieldKeys ?? ['tag-blacklist', 'explored-tags-tracker']
    for (let fieldKey of keys) {
      this._tagComplianceSpecs[fieldKey] = await tagRuntime.getComplianceSpecForField(fieldKey)
    }
  }

  /**
   * @param {string} fieldKey
   * @return {{combos: {tagEntryIds: number[]}[]}|null}
   */
  getTagComplianceSpec(fieldKey)
  {
    return this._tagComplianceSpecs[fieldKey] ?? null
  }

  /**
   * @param {string[]} itemTagNames
   * @param {string} fieldKey
   * @return {{complies: boolean, rule?: string}}
   */
  evaluateTagCompliance(itemTagNames, fieldKey)
  {
    let spec = this.getTagComplianceSpec(fieldKey)
    let tagRuntime = this.getTagRuntime()
    if (!spec || !tagRuntime?._warmed) {
      return {complies: true}
    }
    return tagRuntime.evaluateComplianceSync(itemTagNames, spec)
  }

  /**
   * @param {{typeName?: string|null, typeEntryId?: number|null, replacementName?: string|null, source?: string|null}} [options]
   * @return {{typeName?: string, typeEntryId?: number, replacementName?: string, source?: string}}
   */
  createTagContext(options = {})
  {
    let context = {}
    if (options.typeName) {
      context.typeName = options.typeName
    }
    if (options.typeEntryId != null) {
      context.typeEntryId = options.typeEntryId
    }
    if (options.replacementName) {
      context.replacementName = options.replacementName
    }
    if (options.source) {
      context.source = options.source
    }
    return context
  }

  /**
   * Resolve any tag-type descriptor (canonical name, site class/label, or numeric API category id)
   * to the canonical type name using the `tagTypes` schema. Consumer scripts use this to type tags
   * from site payloads (e.g. autocomplete responses) without hardcoding label→type tables.
   *
   * @see TagRepository#resolveCanonicalTypeName
   * @param {string|number|null|undefined} descriptor
   * @return {Promise<string|null>}
   */
  async resolveCanonicalTypeName(descriptor)
  {
    return await this._repos?.tags?.resolveCanonicalTypeName?.(descriptor) ?? null
  }

  /**
   * Register-on-seen: ensure each tag in typed groups exists in the registry.
   * @param {Record<string, string[]>} groups Map of type name → tag names.
   * @param {string} [source='media']
   * @return {Promise<void>}
   */
  async registerTypedTagGroups(groups, source = 'media')
  {
    let tagRuntime = this.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    await tagRuntime.registerTypedTagGroups(groups, source)
  }

  /**
   * Compile tag rules, warm registry cache, and refresh compliance specs.
   * @return {Promise<void>}
   */
  async prepareTagComplianceRuntime()
  {
    let meta = await this._repos.meta.get()
    let configSeq = meta?.domainConfigSeq ?? 0
    if (this._bootRulesetCompiledConfigSeq !== configSeq) {
      await this.ensureRulesetFieldsCompiled(['tag-blacklist', 'explored-tags-tracker'])
    }
    await this.getTagRuntime()?.warmCache()
    await this.refreshTagComplianceSpecs()
  }

  /**
   * When IndexedDB tagging is active, route download path policy through `TagRuntime`.
   * @param {object} resolver Base resolver (chips, tagTypes, ignore Set, substitutions Map, etc.).
   * @return {object}
   */
  applyTagRegistryDownloadResolver(resolver)
  {
    let tagRuntime = this.getTagRuntime()
    if (this._storageReady && tagRuntime) {
      return {
        ...resolver,
        tagRuntime,
        ignore: null,
        substitutions: null,
      }
    }
    return resolver
  }

  /**
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @return {Promise<void>}
   * @private
   */
  async _commitTagFieldChange(detail = null)
  {
    await this._repos.meta.bumpRevision()
    // Notify first so attribute UIs (discovery panel, sidebar) paint from the writer-updated
    // cache instead of waiting on a full-table warmCache reload.
    this.notifyConfigurationChange('tags', true, detail)
    await this._repos.tagRuntime?.warmCache()
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null}|null} [context]
   * @return {Promise<boolean>}
   */
  async toggleTagRule(fieldKey, tagName, context = null)
  {
    let field = this._config[fieldKey]
    if (!field) {
      return false
    }
    return this._toggleRulesetTagRule(fieldKey, tagName, context)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<boolean>}
   */
  async clearTagRules(fieldKey)
  {
    let field = this._config[fieldKey]
    if (!field || field.type !== CONFIG_TYPE_RULESET) {
      return false
    }
    let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
    for (let row of rows) {
      await this._repos.rulesetEntries.remove(row.entryId)
    }
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, fieldKey)
    }
    await field.reload()
    this._refreshRulesetFieldUiIfClean(field)
    if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([fieldKey])
    }
    await this._commitTagFieldChange(this.createTagsChangeDetail(null, fieldKey))
    return true
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  initializeSyncCompat()
  {
    void this.initialize()
    return this
  }

  /**
   * @param {Function} eventHandler
   * @return {BrazenConfigurationManager}
   */
  onConfigurationChange(eventHandler)
  {
    if (this._onConfigurationChange && this._onConfigurationChange !== eventHandler) {
      console.warn('[BrazenCM] onConfigurationChange already registered — replacing handler')
    }
    this._onConfigurationChange = eventHandler
    return this
  }

  /**
   * @param {ExternalConfigurationChangeCallback} eventHandler
   * @return {BrazenConfigurationManager}
   * @deprecated Use Framework `onConfigurationChange` for script hooks; only Framework should register on CM.
   */
  onExternalConfigurationChange(eventHandler)
  {
    return this.onConfigurationChange(eventHandler)
  }

  /**
   * @param {Response} response
   */
  async restore(response)
  {
    try {
      await this._ensureStorageReady()
      let backupConfig
      if (response instanceof Response) {
        let buffer = await response.arrayBuffer()
        if (buffer.byteLength >= 4 && new DataView(buffer).getUint32(0, true) === 0x04034b50) {
          backupConfig = await this._parseZipBackup(buffer)
        } else {
          backupConfig = JSON.parse(new TextDecoder().decode(buffer))
        }
      } else {
        backupConfig = await new Response(response).json()
      }

      if (backupConfig.version === CONFIG_BACKUP_VERSION && (backupConfig.stores || backupConfig.meta)) {
        await this._restoreIdbBackup(backupConfig)
      } else if (backupConfig.version === 3 && (backupConfig.stores || backupConfig.meta)) {
        await this._restoreIdbBackup(backupConfig)
      } else if (backupConfig.version === 2 && backupConfig.drivers) {
        await this._importV2BackupToIdb(backupConfig)
        await this._recompileAllTagRuleSets()
      } else {
        await this._importLegacyBackupToIdb(backupConfig)
        await this._recompileAllTagRuleSets()
      }

      await this._syncFieldsFromIdb()
      await this._repos.meta.bumpRevision()
      this.notifyConfigurationChange('all', true)
      alert('Brazen script - Backup restored!')
    } catch (error) {
      console.log('Restore failed:', error)
      alert('Brazen script - The supplied backup file seems to have been corrupted!')
    }
  }

  async revertChanges()
  {
    await this._ensureStorageReady()
    this._settingCacheWriteKeys.clear()
    await this._syncFieldsFromIdb()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET) {
        await field.reload()
        field.updateUserInterface()
      }
    }
    this.notifyConfigurationChange('all', true)
    return this
  }

  refreshMountedFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.element) {
        this._overlayFieldFromCache(field)
        field.updateUserInterface()
      }
    }
    return this
  }

  /**
   * @return {Promise<BrazenConfigurationManager>}
   */
  async save()
  {
    await this._ensureStorageReady()
    if (typeof this._commandSender === 'function') {
      await this._commandSender({type: 'config-save', payload: {}})
      this.notifyConfigurationChange('all', true)
      return this
    }
    return this.persistMountedSettingsCoordinator().then(() => {
      this.notifyConfigurationChange('all', true)
      return this
    })
  }

  /**
   * @return {Promise<object[]>}
   * @private
   */
  async _exportBookmarksBackupRows()
  {
    let rows = await this._repos.rulesetEntries.listAllForField('bookmarks')
    return rows.map((entry) => ({
      entryId: entry.entryId,
      label: entry?.payload?.label ?? entry?.rawLine ?? '',
      tags: entry?.payload?.tags ?? '',
      url: entry?.payload?.url ?? '',
      sortOrder: entry.sortOrder ?? 0,
    }))
  }

  /**
   * @return {Promise<void>}
   */
  async backup()
  {
    await this._ensureStorageReady()
    let {zip, filename} = await this._buildBackupZip({purpose: 'userBackup'})
    this._triggerBackupZipDownload(zip, filename)
  }

  /**
   * @param {ArrayBuffer} buffer
   * @return {Promise<{version: number, stores: object}>}
   * @private
   */
  async _parseZipBackup(buffer)
  {
    let files = BrazenZipReader.parse(buffer)
    let manifest = JSON.parse(files['manifest.json'] ?? '{}')
    return {
      version: manifest.version ?? CONFIG_BACKUP_VERSION,
      stores: {
        meta: JSON.parse(files['meta.json'] ?? 'null'),
        settings: JSON.parse(files['settings.json'] ?? 'null'),
        apis: JSON.parse(files['apis.json'] ?? 'null'),
        tagTypes: JSON.parse(files['tagTypes.json'] ?? 'null'),
        tags: collectBackupJsonArrayFromZipFiles(files, 'tags'),
        rulesetFields: JSON.parse(files['rulesetFields.json'] ?? '[]'),
        rulesetEntries: JSON.parse(files['rulesetEntries.json'] ?? '[]'),
        bookmarks: collectBackupJsonArrayFromZipFiles(files, 'bookmarks'),
        ledgerEntries: collectBackupJsonArrayFromZipFiles(files, 'ledgerEntries'),
      },
    }
  }

  /**
   * @param {Object} backupConfig
   * @return {Promise<void>}
   * @private
   */
  async _importV2BackupToIdb(backupConfig)
  {
    let drivers = backupConfig.drivers ?? {}
    let aggregate = {}
    for (let driverKey in drivers) {
      let section = drivers[driverKey]
      if (section && typeof section === 'object') {
        Object.assign(aggregate, section)
      }
    }
    await this._importFlatBackupFieldsToIdb(aggregate)
    if (backupConfig.id !== undefined) {
      let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
      meta.revisionId = String(backupConfig.id)
      await this._repos.meta.put(meta)
    }
  }

  /**
   * @param {Object} backupConfig
   * @return {Promise<void>}
   * @private
   */
  async _importLegacyBackupToIdb(backupConfig)
  {
    let id = backupConfig.id
    let blob = {...backupConfig}
    delete blob.id
    await this._importFlatBackupFieldsToIdb(blob)
    if (id !== undefined) {
      let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
      meta.revisionId = String(id)
      await this._repos.meta.put(meta)
    }
  }

  /**
   * Import a legacy flat bookmarks array into a ruleset field via the bookmarks template.
   * @param {string} fieldKey
   * @param {Array} data
   * @return {Promise<void>}
   * @private
   */
  async _importLegacyBookmarksArray(fieldKey, data)
  {
    if (!Array.isArray(data)) {
      return
    }
    let field = this.getField(fieldKey)
    if (!field || field.type !== CONFIG_TYPE_RULESET) {
      return
    }
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get('bookmarks') : null
    if (!template) {
      return
    }
    let rows = data.
        map((bookmark) => {
          if (!bookmark || typeof bookmark !== 'object') {
            return null
          }
          let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
          if (!tags.length) {
            return null
          }
          let url = typeof bookmark.url === 'string' ? bookmark.url.trim() : ''
          let label = typeof bookmark.label === 'string' && bookmark.label.trim() ?
              bookmark.label.trim() : tags.replaceAll('_', ' ')
          return {
            entryId: bookmark.entryId ?? null,
            label,
            tags,
            url,
            sortOrder: bookmark.sortOrder ?? 0,
          }
        }).
        filter(Boolean)
    let existing = await this._repos.rulesetEntries.listAllForField(fieldKey)
    for (let row of existing) {
      await this._repos.rulesetEntries.remove(row.entryId)
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    for (let index = 0; index < rows.length; index++) {
      let row = rows[index]
      await template.persist({
        label: row.label ?? '',
        tags: row.tags ?? '',
        url: row.url ?? '',
      }, ctx, {
        entryId: row.entryId,
        sortOrder: row.sortOrder ?? index,
      })
    }
    await field.reload?.()
    field.updateUserInterface?.()
  }

  /**
   * @param {Object} blob
   * @return {Promise<void>}
   * @private
   */
  async _importFlatBackupFieldsToIdb(blob)
  {
    if (!blob || typeof blob !== 'object') {
      return
    }
    for (let fieldKey in blob) {
      let field = this._config[fieldKey]
      let value = blob[fieldKey]
      if (!field) {
        continue
      }
      if (field.type === CONFIG_TYPE_LEDGER) {
        await field.merge(value)
      } else if (field.persist === false) {
        if (field.type === CONFIG_TYPE_RULESET && Array.isArray(value)) {
          if (field.templateId === 'bookmarks' || field._rulesetMain?.templateId === 'bookmarks') {
            await this._importLegacyBookmarksArray(fieldKey, value)
          } else {
            await this._importTagFieldFromValue(fieldKey, value)
          }
        } else {
          await field.applyFromBackup?.(value)
        }
      } else {
        let optimized = this._computeFieldOptimized(fieldKey, value)
        await this._repos.settings.putField(fieldKey, value, optimized)
        this._cacheSetting(fieldKey, value, optimized, true)
      }
    }
  }

  /**
   * @param {{stores: object}} backupConfig
   * @return {Promise<void>}
   * @private
   */
  async _restoreIdbBackup(backupConfig)
  {
    let stores = backupConfig.stores ?? backupConfig
    if (stores.meta) {
      await this._repos.storage.put(IDB_STORE_META, stores.meta)
    }
    if (stores.settings) {
      await this._repos.storage.put(IDB_STORE_SETTINGS, stores.settings)
    }
    if (stores.apis) {
      await this._repos.storage.put(IDB_STORE_APIS, stores.apis)
    }
    if (stores.tagTypes) {
      await this._repos.storage.put(IDB_STORE_TAG_TYPES, stores.tagTypes)
    }
    if (stores.tags) {
      await this._repos.storage.clearStore(IDB_STORE_TAGS)
      await this._repos.storage.putMany(IDB_STORE_TAGS, stores.tags)
    }
    if (stores.rulesetFields?.length) {
      await this._repos.storage.clearStore(IDB_STORE_RULESET_FIELDS)
      for (let row of stores.rulesetFields) {
        await this._repos.storage.put(IDB_STORE_RULESET_FIELDS, row)
      }
    }
    if (stores.rulesetEntries?.length) {
      await this._repos.storage.clearStore(IDB_STORE_RULESET_ENTRIES)
      await this._repos.storage.putMany(IDB_STORE_RULESET_ENTRIES, stores.rulesetEntries)
    }
    if (stores.bookmarks?.length) {
      await this._importLegacyBookmarksArray('bookmarks', stores.bookmarks)
    }
    if (stores.ledgerEntries) {
      await this._repos.ledger.mergeRows(stores.ledgerEntries)
    }
    await this._repos.meta.bumpRevision()
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  update()
  {
    let field
    for (let fieldName in this._config) {
      field = this._config[fieldName]
      if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_LEDGER ||
          field.persist === false) {
        continue
      }
      if (field.element) {
        field.setFromUserInterface()
        if (this._storageReady && field.persist !== false) {
          this._cacheSetting(field.key, field.value, this._computeFieldOptimized(field.key, field.value))
        }
      }
    }
    return this
  }

  /**
   * Reload scalar settings cache from IDB (same path as foreign-revision resync).
   * Call before the first `updateInterface()` after init work that may have bumped config revision.
   * @return {Promise<BrazenConfigurationManager>}
   */
  async refreshSettingCacheFromStorage()
  {
    if (!this._storageReady) {
      return this
    }
    await this._syncFieldsFromIdb()
    return this
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  updateInterface()
  {
    let field
    for (let fieldName in this._config) {
      field = this._config[fieldName]
      if (field.element) {
        this._overlayFieldFromCache(field)
        field.updateUserInterface()
      }
    }
    if (this._dockActive) {
      this.refreshDockButtonStates()
    }
    return this
  }
}

BrazenConfigurationManager.DEFAULT_RULESET_USER_CONFIG = DEFAULT_RULESET_USER_CONFIG
BrazenConfigurationManager.RULESET_USER_CONFIG_KEYS = RULESET_USER_CONFIG_KEYS