tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

FeatureCallout.sys.mjs (95985B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
      9  ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
     10  CustomizableUI:
     11    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
     12  PageEventManager: "resource:///modules/asrouter/PageEventManager.sys.mjs",
     13 });
     14 
     15 const TRANSITION_MS = 500;
     16 const CONTAINER_ID = "feature-callout";
     17 const CONTENT_BOX_ID = "multi-stage-message-root";
     18 const BUNDLE_SRC =
     19  "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js";
     20 
     21 ChromeUtils.defineLazyGetter(lazy, "log", () => {
     22  const { Logger } = ChromeUtils.importESModule(
     23    "resource://messaging-system/lib/Logger.sys.mjs"
     24  );
     25  return new Logger("FeatureCallout");
     26 });
     27 
     28 /**
     29 * Feature Callout fetches messages relevant to a given source and displays them
     30 * in the parent page pointing to the element they describe.
     31 */
     32 export class FeatureCallout {
     33  /**
     34   * @typedef {object} FeatureCalloutOptions
     35   * @property {Window} win window in which messages will be rendered.
     36   * @property {{name: string, defaultValue?: string}} [pref] optional pref used
     37   *   to track progress through a given feature tour. for example:
     38   *   {
     39   *     name: "browser.pdfjs.feature-tour",
     40   *     defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
     41   *   }
     42   *   or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
     43   * @property {string} [location] string to pass as the page when requesting
     44   *   messages from ASRouter and sending telemetry.
     45   * @property {string} context either "chrome" or "content". "chrome" is used
     46   *   when the callout is shown in the browser chrome, and "content" is used
     47   *   when the callout is shown in a content page like Firefox View.
     48   * @property {MozBrowser} [browser] <browser> element responsible for the
     49   *   feature callout. for content pages, this is the browser element that the
     50   *   callout is being shown in. for chrome, this is the active browser.
     51   * @property {Function} [listener] callback to be invoked on various callout
     52   *   events to keep the broker informed of the callout's state.
     53   * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
     54   */
     55 
     56  /** @param {FeatureCalloutOptions} options */
     57  constructor({
     58    win,
     59    pref,
     60    location,
     61    context,
     62    browser,
     63    listener,
     64    theme = {},
     65  } = {}) {
     66    this.win = win;
     67    this.doc = win.document;
     68    this.browser = browser || this.win.docShell.chromeEventHandler;
     69    this.config = null;
     70    this.loadingConfig = false;
     71    this.message = null;
     72    if (pref?.name) {
     73      this.pref = pref;
     74    }
     75    this._featureTourProgress = null;
     76    this.currentScreen = null;
     77    this.renderObserver = null;
     78    this.savedFocus = null;
     79    this.ready = false;
     80    this._positionListenersRegistered = false;
     81    this._panelConflictListenersRegistered = false;
     82    this.AWSetup = false;
     83    this.location = location;
     84    this.context = context;
     85    this.listener = listener;
     86    this._initTheme(theme);
     87 
     88    this._handlePrefChange = this._handlePrefChange.bind(this);
     89 
     90    this.setupFeatureTourProgress();
     91 
     92    // When the window is focused, ensure tour is synced with tours in any other
     93    // instances of the parent page. This does not apply when the Callout is
     94    // shown in the browser chrome.
     95    if (this.context !== "chrome") {
     96      this.win.addEventListener("visibilitychange", this);
     97    }
     98 
     99    this.win.addEventListener("unload", this);
    100  }
    101 
    102  setupFeatureTourProgress() {
    103    if (this.featureTourProgress) {
    104      return;
    105    }
    106    if (this.pref?.name) {
    107      this._handlePrefChange(null, null, this.pref.name);
    108      Services.prefs.addObserver(this.pref.name, this._handlePrefChange);
    109    }
    110  }
    111 
    112  teardownFeatureTourProgress() {
    113    if (this.pref?.name) {
    114      Services.prefs.removeObserver(this.pref.name, this._handlePrefChange);
    115    }
    116    this._featureTourProgress = null;
    117  }
    118 
    119  get featureTourProgress() {
    120    return this._featureTourProgress;
    121  }
    122 
    123  /**
    124   * Get the page event manager and instantiate it if necessary. Only used by
    125   * _attachPageEventListeners, since we don't want to do this unnecessary work
    126   * if a message with page event listeners hasn't loaded. Other consumers
    127   * should use `this._pageEventManager?.property` instead.
    128   */
    129  get _loadPageEventManager() {
    130    if (!this._pageEventManager) {
    131      this._pageEventManager = new lazy.PageEventManager(this.win);
    132    }
    133    return this._pageEventManager;
    134  }
    135 
    136  _addPositionListeners() {
    137    if (!this._positionListenersRegistered) {
    138      this.win.addEventListener("resize", this);
    139      this._positionListenersRegistered = true;
    140    }
    141  }
    142 
    143  _removePositionListeners() {
    144    if (this._positionListenersRegistered) {
    145      this.win.removeEventListener("resize", this);
    146      this._positionListenersRegistered = false;
    147    }
    148  }
    149 
    150  _addPanelConflictListeners() {
    151    if (!this._panelConflictListenersRegistered) {
    152      this.win.addEventListener("popupshowing", this);
    153      this.win.gURLBar.controller.addListener(this);
    154      this._panelConflictListenersRegistered = true;
    155    }
    156  }
    157 
    158  _removePanelConflictListeners() {
    159    if (this._panelConflictListenersRegistered) {
    160      this.win.removeEventListener("popupshowing", this);
    161      this.win.gURLBar.controller.removeListener(this);
    162      this._panelConflictListenersRegistered = false;
    163    }
    164  }
    165 
    166  /**
    167   * Close the tour when the urlbar is opened in the chrome. Set up by
    168   * gURLBar.controller.addListener in _addPanelConflictListeners.
    169   */
    170  onViewOpen() {
    171    this.endTour();
    172  }
    173 
    174  _handlePrefChange(subject, topic, prefName) {
    175    switch (prefName) {
    176      case this.pref?.name:
    177        try {
    178          this._featureTourProgress = JSON.parse(
    179            Services.prefs.getStringPref(
    180              this.pref.name,
    181              this.pref.defaultValue ?? null
    182            )
    183          );
    184        } catch (error) {
    185          this._featureTourProgress = null;
    186        }
    187        if (topic === "nsPref:changed") {
    188          this._advanceOnTourPrefChange();
    189        }
    190        break;
    191    }
    192  }
    193 
    194  /**
    195   * @typedef {object} AdvanceScreensOptions
    196   * @property {boolean | "actionResult"} [behavior] Set to true to take effect
    197   *   immediately, or set to "actionResult" to only advance screens after the
    198   *   special message action has resolved successfully. "actionResult" requires
    199   *   `action.needsAwait` to be true. Defaults to true.
    200   * @property {string} [id] The id of the screen to advance to. If both id and
    201   *   direction are provided (which they shouldn't be), the id takes priority.
    202   *   Either `id` or `direction` is required. Passing `%end%` ends the tour.
    203   * @property {number} [direction] How many screens, and in which direction, to
    204   *   advance. Positive integers advance forward, negative integers advance
    205   *   backward. Must be an integer. If advancing by the specified number of
    206   *   screens would take you beyond the last screen, it will end the tour, just
    207   *   like if you used `dismiss: true`. If it's a negative integer that
    208   *   advances beyond the first screen, it will stop at the first screen.
    209   */
    210 
    211  /** @param {AdvanceScreensOptions} options */
    212  _advanceScreens({ id, direction } = {}) {
    213    if (!this.currentScreen) {
    214      lazy.log.error(
    215        `In ${this.location}: Cannot advance screens without a current screen.`
    216      );
    217      return;
    218    }
    219    if ((!direction || !Number.isInteger(direction)) && !id) {
    220      lazy.log.debug(
    221        `In ${this.location}: Cannot advance screens without a valid direction or id.`
    222      );
    223      return;
    224    }
    225    if (id === "%end%") {
    226      // Special case for ending the tour. When `id` is `%end%`, we should end
    227      // the tour and clear the current screen.
    228      this.endTour();
    229      return;
    230    }
    231    let nextIndex = -1; // Default to -1 to indicate an invalid index.
    232    let currentIndex = this.config.screens.findIndex(
    233      screen => screen.id === this.currentScreen.id
    234    );
    235    if (id) {
    236      nextIndex = this.config.screens.findIndex(screen => screen.id === id);
    237      if (nextIndex === -1) {
    238        lazy.log.debug(
    239          `In ${this.location}: Unable to find screen with id: ${id}`
    240        );
    241        return;
    242      }
    243      if (nextIndex === currentIndex) {
    244        lazy.log.debug(
    245          `In ${this.location}: Already on screen with id: ${id}. Not advancing.`
    246        );
    247        return;
    248      }
    249    } else {
    250      // Calculate the next index based on the current screen and direction.
    251      nextIndex = Math.max(0, currentIndex + direction);
    252    }
    253    if (nextIndex < 0) {
    254      // Don't allow going before the first screen.
    255      lazy.log.debug(
    256        `In ${this.location}: Cannot advance before the first screen.`
    257      );
    258      return;
    259    }
    260    if (nextIndex >= this.config.screens.length) {
    261      // Allow ending the tour if we would go beyond the last screen.
    262      this.endTour();
    263      return;
    264    }
    265 
    266    this.ready = false;
    267    this._container?.classList.toggle(
    268      "hidden",
    269      this._container?.localName !== "panel"
    270    );
    271    this._pageEventManager?.emit({
    272      type: "touradvance",
    273      target: this._container,
    274    });
    275    const onFadeOut = async () => {
    276      this._container?.remove();
    277      this.renderObserver?.disconnect();
    278      this._removePositionListeners();
    279      this._removePanelConflictListeners();
    280      this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
    281      if (this.message) {
    282        const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage(
    283          this.message
    284        );
    285        if (!isMessageUnblocked) {
    286          this.endTour();
    287          return;
    288        }
    289      }
    290      let updated = await this._updateConfig(this.message, nextIndex);
    291      if (!updated && !this.currentScreen) {
    292        this.endTour();
    293        return;
    294      }
    295      let rendering = await this._renderCallout();
    296      if (!rendering) {
    297        this.endTour();
    298      }
    299    };
    300    if (this._container?.localName === "panel") {
    301      this._container.removeEventListener("popuphiding", this);
    302      const controller = new AbortController();
    303      this._container.addEventListener(
    304        "popuphidden",
    305        event => {
    306          if (event.target === this._container) {
    307            controller.abort();
    308            onFadeOut();
    309          }
    310        },
    311        { signal: controller.signal }
    312      );
    313      this._container.hidePopup(true);
    314    } else {
    315      this.win.setTimeout(onFadeOut, TRANSITION_MS);
    316    }
    317  }
    318 
    319  _advanceOnTourPrefChange() {
    320    if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
    321      return;
    322    }
    323 
    324    // If we have more than one screen, it means that we're displaying a feature
    325    // tour, and transitions are handled based on the value of a tour progress
    326    // pref. Otherwise, just show the feature callout. If a pref change results
    327    // from an event in a Spotlight message, initialize the feature callout with
    328    // the next message in the tour.
    329    if (
    330      this.config?.screens.length === 1 ||
    331      this.currentScreen === "spotlight"
    332    ) {
    333      this.showFeatureCallout();
    334      return;
    335    }
    336 
    337    let prefVal = this.featureTourProgress;
    338    // End the tour according to the tour progress pref or if the user disabled
    339    // contextual feature recommendations.
    340    if (prefVal.complete) {
    341      this.endTour();
    342    } else if (prefVal.screen !== this.currentScreen?.id) {
    343      // Pref changes only matter to us insofar as they let us advance an
    344      // ongoing tour. If the tour was closed and the pref changed later, e.g.
    345      // by editing the pref directly, we don't want to start up the tour again.
    346      // This is more important in the chrome, which is always open.
    347      if (this.context === "chrome" && !this.currentScreen) {
    348        return;
    349      }
    350      this.ready = false;
    351      this._container?.classList.toggle(
    352        "hidden",
    353        this._container?.localName !== "panel"
    354      );
    355      this._pageEventManager?.emit({
    356        type: "touradvance",
    357        target: this._container,
    358      });
    359      const onFadeOut = async () => {
    360        // If the initial message was deployed from outside by ASRouter as a
    361        // result of a trigger, we can't continue it through _loadConfig, since
    362        // that effectively requests a message with a `featureCalloutCheck`
    363        // trigger. So we need to load up the same message again, merely
    364        // changing the startScreen index. Just check that the next screen and
    365        // the current screen are both within the message's screens array.
    366        let nextMessage = null;
    367        if (
    368          this.context === "chrome" &&
    369          this.message?.trigger.id !== "featureCalloutCheck"
    370        ) {
    371          if (
    372            this.config?.screens.some(s => s.id === this.currentScreen?.id) &&
    373            this.config.screens.some(s => s.id === prefVal.screen)
    374          ) {
    375            nextMessage = this.message;
    376          }
    377        }
    378        this._container?.remove();
    379        this.renderObserver?.disconnect();
    380        this._removePositionListeners();
    381        this._removePanelConflictListeners();
    382        this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
    383        if (nextMessage) {
    384          const isMessageUnblocked =
    385            await lazy.ASRouter.isUnblockedMessage(nextMessage);
    386          if (!isMessageUnblocked) {
    387            this.endTour();
    388            return;
    389          }
    390        }
    391        let updated = await this._updateConfig(nextMessage);
    392        if (!updated && !this.currentScreen) {
    393          this.endTour();
    394          return;
    395        }
    396        let rendering = await this._renderCallout();
    397        if (!rendering) {
    398          this.endTour();
    399        }
    400      };
    401      if (this._container?.localName === "panel") {
    402        this._container.removeEventListener("popuphiding", this);
    403        const controller = new AbortController();
    404        this._container.addEventListener(
    405          "popuphidden",
    406          event => {
    407            if (event.target === this._container) {
    408              controller.abort();
    409              onFadeOut();
    410            }
    411          },
    412          { signal: controller.signal }
    413        );
    414        this._container.hidePopup(true);
    415      } else {
    416        this.win.setTimeout(onFadeOut, TRANSITION_MS);
    417      }
    418    }
    419  }
    420 
    421  handleEvent(event) {
    422    switch (event.type) {
    423      case "focus": {
    424        if (!this._container) {
    425          return;
    426        }
    427        // If focus has fired on the feature callout window itself, or on something
    428        // contained in that window, ignore it, as we can't possibly place the focus
    429        // on it after the callout is closd.
    430        if (
    431          event.target === this._container ||
    432          (Node.isInstance(event.target) &&
    433            this._container.contains(event.target))
    434        ) {
    435          return;
    436        }
    437        // Save this so that if the next focus event is re-entering the popup,
    438        // then we'll put the focus back here where the user left it once we exit
    439        // the feature callout series.
    440        if (this.doc.activeElement) {
    441          let element = this.doc.activeElement;
    442          this.savedFocus = {
    443            element,
    444            focusVisible: element.matches(":focus-visible"),
    445          };
    446        } else {
    447          this.savedFocus = null;
    448        }
    449        break;
    450      }
    451 
    452      case "keypress": {
    453        if (event.key !== "Escape") {
    454          return;
    455        }
    456        if (!this._container) {
    457          return;
    458        }
    459        this.win.AWSendEventTelemetry?.({
    460          event: "DISMISS",
    461          event_context: {
    462            source: `KEY_${event.key}`,
    463            page: this.location,
    464          },
    465          message_id: this.config?.id.toUpperCase(),
    466        });
    467        this._dismiss();
    468        event.preventDefault();
    469        break;
    470      }
    471 
    472      case "visibilitychange":
    473        this._advanceOnTourPrefChange();
    474        break;
    475 
    476      case "resize":
    477      case "toggle":
    478        this.win.requestAnimationFrame(() => this._positionCallout());
    479        break;
    480 
    481      case "popupshowing":
    482        // If another panel is showing, close the tour.
    483        if (
    484          event.target.ownerGlobal === this.win &&
    485          event.target !== this._container &&
    486          event.target.localName === "panel" &&
    487          event.target.id !== "ctrlTab-panel" &&
    488          event.target.getAttribute("noautohide") !== "true"
    489        ) {
    490          this.endTour();
    491        }
    492        break;
    493 
    494      case "popuphiding":
    495        if (event.target === this._container) {
    496          this.endTour();
    497        }
    498        break;
    499 
    500      case "unload":
    501        try {
    502          this.teardownFeatureTourProgress();
    503        } catch (error) {}
    504        break;
    505 
    506      default:
    507    }
    508  }
    509 
    510  async _addCalloutLinkElements() {
    511    for (const path of [
    512      "browser/newtab/onboarding.ftl",
    513      "browser/spotlight.ftl",
    514      "branding/brand.ftl",
    515      "toolkit/branding/brandings.ftl",
    516      "browser/newtab/asrouter.ftl",
    517      "browser/featureCallout.ftl",
    518    ]) {
    519      this.win.MozXULElement.insertFTLIfNeeded(path);
    520    }
    521 
    522    const addChromeSheet = href => {
    523      try {
    524        this.win.windowUtils.loadSheetUsingURIString(
    525          href,
    526          Ci.nsIDOMWindowUtils.AUTHOR_SHEET
    527        );
    528      } catch (error) {
    529        // the sheet was probably already loaded. I don't think there's a way to
    530        // check for this via JS, but the method checks and throws if it's
    531        // already loaded, so we can just treat the error as expected.
    532      }
    533    };
    534    const addStylesheet = href => {
    535      if (this.win.isChromeWindow) {
    536        // for chrome, load the stylesheet using a special method to make sure
    537        // it's loaded synchronously before the first paint & position.
    538        return addChromeSheet(href);
    539      }
    540      if (this.doc.querySelector(`link[href="${href}"]`)) {
    541        return null;
    542      }
    543      const link = this.doc.head.appendChild(this.doc.createElement("link"));
    544      link.rel = "stylesheet";
    545      link.href = href;
    546      return null;
    547    };
    548    // Update styling to be compatible with about:welcome bundle
    549    await addStylesheet(
    550      "chrome://browser/content/aboutwelcome/aboutwelcome.css"
    551    );
    552  }
    553 
    554  /**
    555   * @typedef {
    556   * | "topleft"
    557   * | "topright"
    558   * | "bottomleft"
    559   * | "bottomright"
    560   * | "leftcenter"
    561   * | "rightcenter"
    562   * | "topcenter"
    563   * | "bottomcenter"
    564   * } PopupAttachmentPoint
    565   *
    566   * @see nsMenuPopupFrame
    567   *
    568   * Each attachment point corresponds to an attachment point on the edge of a
    569   * frame. For example, "topleft" corresponds to the frame's top left corner,
    570   * and "rightcenter" corresponds to the center of the right edge of the frame.
    571   */
    572 
    573  /**
    574   * @typedef {object} PanelPosition Specifies how the callout panel should be
    575   *   positioned relative to the anchor element, by providing which point on
    576   *   the callout should be aligned with which point on the anchor element.
    577   * @property {PopupAttachmentPoint} anchor_attachment
    578   * @property {PopupAttachmentPoint} callout_attachment
    579   * @property {string} [panel_position_string] The attachments joined into a
    580   *   string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup.
    581   *   This is not provided by JSON, but generated from anchor_attachment and
    582   *   callout_attachment.
    583   * @property {number} [offset_x] Offset in pixels to apply to the callout
    584   *   position in the horizontal direction.
    585   * @property {number} [offset_y] The same in the vertical direction.
    586   *
    587   * This is used when you want the callout to be displayed as a <panel>
    588   * element. A panel is critical when the callout is displayed in the browser
    589   * chrome, anchored to an element whose position on the screen is dynamic,
    590   * such as a button. When the anchor moves, the panel will automatically move
    591   * with it. Also, when the elements are aligned so that the callout would
    592   * extend beyond the edge of the screen, the panel will automatically flip
    593   * itself to the other side of the anchor element. This requires specifying
    594   * both an anchor attachment point and a callout attachment point. For
    595   * example, to get the callout to appear under a button, with its arrow on the
    596   * right side of the callout:
    597   * { anchor_attachment: "bottomcenter", callout_attachment: "topright" }
    598   */
    599 
    600  /**
    601   * @typedef {
    602   * | "top"
    603   * | "bottom"
    604   * | "end"
    605   * | "start"
    606   * | "top-end"
    607   * | "top-start"
    608   * | "top-center-arrow-end"
    609   * | "top-center-arrow-start"
    610   * } HTMLArrowPosition
    611   *
    612   * @see FeatureCallout._positionCallout()
    613   * The position of the callout arrow relative to the callout container. Only
    614   * used for HTML callouts, typically in content pages. If the position
    615   * contains a dash, the value before the dash refers to which edge of the
    616   * feature callout the arrow points from. The value after the dash describes
    617   * where along that edge the arrow sits, with middle as the default.
    618   */
    619 
    620  /**
    621   * @typedef {object} PositionOverride CSS properties to override
    622   *   the callout's position relative to the anchor element. Although the
    623   *   callout is not actually a child of the anchor element, this allows
    624   *   absolute positioning of the callout relative to the anchor element. In
    625   *   other words, { top: "0px", left: "0px" } will position the callout in the
    626   *   top left corner of the anchor element, in the same way these properties
    627   *   would position a child element.
    628   * @property {string} [top]
    629   * @property {string} [left]
    630   * @property {string} [right]
    631   * @property {string} [bottom]
    632   */
    633 
    634  /**
    635   * @typedef {object} AutoFocusOptions For the optional autofocus feature.
    636   * @property {string} [selector] A preferred CSS selector, if you want a
    637   *   specific element to be focused. If omitted, the default prioritization
    638   *   listed below will be used, based on `use_defaults`.
    639   * Default prioritization: primary_button, secondary_button, additional_button
    640   *   (excluding pseudo-links), dismiss_button, <input>, any button.
    641   * @property {boolean} [use_defaults] Whether to use the default element
    642   *   prioritization. If `selector` is provided and the element can't be found,
    643   *   and this is set to false, nothing will be selected. If `selector` is not
    644   *   provided, this must be true. Defaults to true.
    645   */
    646 
    647  /**
    648   * @typedef {object} Anchor
    649   * @property {string} selector CSS selector for the anchor node.
    650   * @property {Element} [element] The anchor node resolved from the selector.
    651   *   Not provided by JSON, but generated dynamically.
    652   * @property {PanelPosition} [panel_position] Used to show the callout in a
    653   *   XUL panel. Only works in chrome documents, like the main browser window.
    654   * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in
    655   *   an HTML div container. Mutually exclusive with panel_position.
    656   * @property {PositionOverride} [absolute_position] Only used for HTML
    657   *   callouts, i.e. when panel_position is not specified. Allows absolute
    658   *   positioning of the callout relative to the anchor element.
    659   * @property {boolean} [hide_arrow] Whether to hide the arrow.
    660   * @property {boolean} [no_open_on_anchor] Whether to set the [open] style on
    661   *   the anchor element when the callout is shown. False to set it, true to
    662   *   not set it. This only works for panel callouts. Not all elements have an
    663   *   [open] style. Buttons do, for example. It's usually similar to :active.
    664   * @property {number} [arrow_width] The desired width of the arrow in a number
    665   *   of pixels. 33.94113 by default (this corresponds to 24px edges).
    666   * @property {AutoFocusOptions} [autofocus] Options for the optional autofocus
    667   *   feature. Typically omitted, but if provided, an element inside the
    668   *   callout will be automatically focused when the callout appears.
    669   */
    670 
    671  /**
    672   * Return the first visible anchor element for the current screen. Screens can
    673   * specify multiple anchors in an array, and the first one that is visible
    674   * will be used. If none are visible, return null.
    675   *
    676   * @returns {Anchor|null}
    677   */
    678  _getAnchor() {
    679    /** @type {Anchor[]} */
    680    const anchors = Array.isArray(this.currentScreen?.anchors)
    681      ? this.currentScreen.anchors
    682      : [];
    683    for (let anchor of anchors) {
    684      if (!anchor || typeof anchor !== "object") {
    685        lazy.log.debug(
    686          `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}`
    687        );
    688        continue;
    689      }
    690      let { selector, arrow_position, panel_position } = anchor;
    691      if (!selector) {
    692        continue; // No selector provided.
    693      }
    694      if (panel_position) {
    695        let panel_position_string =
    696          this._getPanelPositionString(panel_position);
    697        // if the positionString doesn't match the format we expect, don't
    698        // render the callout.
    699        if (!panel_position_string && !arrow_position) {
    700          lazy.log.debug(
    701            `In ${
    702              this.location
    703            }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify(
    704              panel_position
    705            )}`
    706          );
    707          continue;
    708        }
    709        panel_position.panel_position_string = panel_position_string;
    710      }
    711      if (
    712        arrow_position &&
    713        !this._HTMLArrowPositions.includes(arrow_position)
    714      ) {
    715        lazy.log.debug(
    716          `In ${
    717            this.location
    718          }: Invalid arrow_position config. Expected one of ${JSON.stringify(
    719            this._HTMLArrowPositions
    720          )}, got: ${arrow_position}`
    721        );
    722        continue;
    723      }
    724 
    725      const resolvedSelectorAndScope = this._resolveSelectorAndScope(selector);
    726      // Attempt to resolve the selector into a usable DOM context.
    727      // Handles special tokens like %triggerTab%, shadow DOM traversal (::%shadow%), etc.
    728      // If resolution fails (e.g., element is not visible or overflows), returns null.
    729      // Applies to both plain selectors and tokenized ones.
    730      if (!resolvedSelectorAndScope) {
    731        continue;
    732      }
    733      const { scope, selector: resolvedSelector } = resolvedSelectorAndScope;
    734      let element = scope.querySelector(resolvedSelector);
    735 
    736      // The element may not be a child of the scope, but the scope itself. For
    737      // example, if we're anchoring directly to the trigger tab, our selector
    738      // might look like `%triggerTab%[visuallyselected]`. In this case,
    739      // querySelector() will return nothing, but matches() will return true.
    740      if (!element && scope.matches?.(resolvedSelector)) {
    741        element = scope;
    742      }
    743      if (!element) {
    744        continue; // Element doesn't exist at all.
    745      }
    746      if (!this._isElementVisible(element)) {
    747        continue;
    748      }
    749      if (
    750        this.context === "chrome" &&
    751        element.id &&
    752        selector.includes(`#${element.id}`)
    753      ) {
    754        let widget = lazy.CustomizableUI.getWidget(element.id);
    755        if (
    756          widget &&
    757          (this.win.CustomizationHandler.isCustomizing() ||
    758            widget.areaType?.includes("panel"))
    759        ) {
    760          // The element is a customizable widget (a toolbar item, e.g. the
    761          // reload button or the downloads button). Widgets can be in various
    762          // areas, like the overflow panel or the customization palette.
    763          // Widgets in the palette are present in the chrome's DOM during
    764          // customization, but can't be used.
    765          continue;
    766        }
    767      }
    768      return { ...anchor, element };
    769    }
    770    return null;
    771  }
    772 
    773  _isElementVisible(el) {
    774    if (
    775      this.context === "chrome" &&
    776      typeof this.win.isElementVisible === "function" &&
    777      !this.win.isElementVisible(el)
    778    ) {
    779      return false;
    780    }
    781 
    782    const style = this.win.getComputedStyle(el);
    783    return style?.visibility === "visible" && style?.display !== "none";
    784  }
    785 
    786  /**
    787   * Resolves selector tokens into a usable scope and selector.
    788   *
    789   * The selector string may contain custom tokens that are substituted and resolved
    790   * against the appropriate DOM context.
    791   *
    792   * Supported custom tokens:
    793   * - %triggerTab%: The <tab> element associated with the current browser.
    794   * - %triggeredTabBookmark%: Bookmark item in the toolbar matching the current tab's URL or label.
    795   * - ::%shadow%: Traverses nested shadow DOM boundaries.
    796   *
    797   * @param {string} selector
    798   * @returns {{scope: Element, selector: string} | null}
    799   */
    800  _resolveSelectorAndScope(selector) {
    801    let scope = this.doc.documentElement;
    802    let normalizedSelector = selector;
    803 
    804    // %triggerTab%
    805    if (this.browser && normalizedSelector.includes("%triggerTab%")) {
    806      const triggerTab = this.browser.ownerGlobal.gBrowser?.getTabForBrowser(
    807        this.browser
    808      );
    809      if (!triggerTab) {
    810        lazy.log.debug(
    811          `In ${this.location}: Failed to resolve %triggerTab% in selector: ${selector}`
    812        );
    813        return null;
    814      }
    815      scope = triggerTab;
    816      normalizedSelector = normalizedSelector.replace("%triggerTab%", ":scope");
    817    }
    818 
    819    // %triggeredTabBookmark%
    820    if (normalizedSelector.includes("%triggeredTabBookmark%")) {
    821      const gBrowser = this.browser?.ownerGlobal?.gBrowser;
    822      const tab = gBrowser?.getTabForBrowser(this.browser);
    823      const url = this.browser?.currentURI?.spec;
    824      const label = tab?.label;
    825      const toolbar = this.doc.getElementById("PersonalToolbar");
    826      const scrollbox = this.doc.getElementById("PlacesToolbarItems");
    827 
    828      // Early return if the toolbar is collapsed or missing context
    829      if (!toolbar || toolbar.collapsed || !scrollbox || (!url && !label)) {
    830        lazy.log.debug(
    831          `In ${this.location}: Bookmarks toolbar is collapsed: ${selector}`
    832        );
    833        return null;
    834      }
    835 
    836      // If a selector prefix is provided before the %triggeredTabBookmark% token,
    837      // resolve it as the root scope. If there's a selector suffix after the token,
    838      // it is appended to the resolved element using :scope as the base,
    839      // e.g. `:root %triggeredTabBookmark% > image` becomes `:scope > image`.
    840      const [preTokenSelector, postTokenSelector = ""] =
    841        normalizedSelector.split("%triggeredTabBookmark%");
    842      const rootScope = preTokenSelector.trim()
    843        ? this.doc.querySelector(preTokenSelector.trim())
    844        : this.doc;
    845 
    846      const match = [
    847        ...rootScope.querySelectorAll("#PlacesToolbarItems .bookmark-item"),
    848      ].find(el => {
    849        const node = el._placesNode;
    850        return (
    851          node &&
    852          (node.uri === url ||
    853            [this.browser.contentTitle, url].includes(node.title) ||
    854            el.getAttribute("label") === label)
    855        );
    856      });
    857 
    858      if (!match) {
    859        lazy.log.debug(
    860          `In ${this.location}: No bookmark matched. Tab URL: ${url}, Tab Title: ${this.browser.contentTitle}, History Title: ${this._cachedHistoryTitle?.title}`
    861        );
    862        return null;
    863      }
    864 
    865      if (!this._isElementVisible(match)) {
    866        lazy.log.debug(
    867          `In ${this.location}: Bookmark item is not visible or overflowed: ${selector}`
    868        );
    869        return null;
    870      }
    871 
    872      scope = match;
    873      normalizedSelector = `:scope${postTokenSelector}`;
    874    }
    875 
    876    // ::%shadow%
    877    if (normalizedSelector.includes("::%shadow%")) {
    878      let parts = normalizedSelector.split("::%shadow%");
    879      for (let i = 0; i < parts.length; i++) {
    880        normalizedSelector = parts[i].trim();
    881        if (i === parts.length - 1) {
    882          break;
    883        }
    884        let el = scope.querySelector(normalizedSelector);
    885        if (!el) {
    886          break;
    887        }
    888        if (el.shadowRoot) {
    889          scope = el.shadowRoot;
    890        }
    891      }
    892    }
    893 
    894    // Attempts to resolve the final element using the normalized selector and
    895    // scope. If querySelector fails (e.g. when the selector is ":scope"), fall
    896    // back to checking whether the scope itself matches the selector. This is
    897    // necessary for cases like "%triggeredTabBookmark%" or "%triggerTab%",
    898    // where the target element is the scope.
    899    let element = scope.querySelector(normalizedSelector);
    900    if (!element && scope.matches?.(normalizedSelector)) {
    901      element = scope;
    902    }
    903    if (!element || !this._isElementVisible(element)) {
    904      lazy.log.debug(
    905        `In ${this.location}: Selector failed to resolve or is not visible: ${normalizedSelector}`
    906      );
    907      return null;
    908    }
    909 
    910    // Use the matched element as the anchor by returning it as scope,
    911    // and ":scope" as the selector so it matches itself in _getAnchor().
    912    return { scope: element, selector: ":scope" };
    913  }
    914 
    915  /** @see PopupAttachmentPoint */
    916  _popupAttachmentPoints = [
    917    "topleft",
    918    "topright",
    919    "bottomleft",
    920    "bottomright",
    921    "leftcenter",
    922    "rightcenter",
    923    "topcenter",
    924    "bottomcenter",
    925  ];
    926 
    927  /**
    928   * Return a string representing the position of the panel relative to the
    929   * anchor element. Passed to XULPopupElement::openPopup. The string is of the
    930   * form "anchor_attachment callout_attachment".
    931   *
    932   * @param {PanelPosition} panelPosition
    933   * @returns {string | null} A string like "bottomcenter topright", or null if
    934   *   the panelPosition object is invalid.
    935   */
    936  _getPanelPositionString(panelPosition) {
    937    const { anchor_attachment, callout_attachment } = panelPosition;
    938    if (
    939      !this._popupAttachmentPoints.includes(anchor_attachment) ||
    940      !this._popupAttachmentPoints.includes(callout_attachment)
    941    ) {
    942      return null;
    943    }
    944    let positionString = `${anchor_attachment} ${callout_attachment}`;
    945    return positionString;
    946  }
    947 
    948  /**
    949   * Set/override methods on a panel element. Can be used to override methods on
    950   * the custom element class, or to add additional methods.
    951   *
    952   * @param {MozPanel} panel The panel to set methods for
    953   */
    954  _setPanelMethods(panel) {
    955    // This method is optionally called by MozPanel::_setSideAttribute, though
    956    // it does not exist on the class.
    957    panel.setArrowPosition = function setArrowPosition(event) {
    958      if (!this.hasAttribute("show-arrow")) {
    959        return;
    960      }
    961      let { alignmentPosition, alignmentOffset, popupAlignment } = event;
    962      let positionParts = alignmentPosition?.match(
    963        /^(before|after|start|end)_(before|after|start|end)$/
    964      );
    965      if (!positionParts) {
    966        return;
    967      }
    968      // Hide the arrow if the `flip` behavior has caused the panel to
    969      // offset relative to its anchor, since the arrow would no longer
    970      // point at the true anchor. This differs from an arrow that is
    971      // intentionally hidden by the user in message.
    972      if (this.getAttribute("hide-arrow") !== "permanent") {
    973        if (alignmentOffset) {
    974          this.setAttribute("hide-arrow", "temporary");
    975        } else {
    976          this.removeAttribute("hide-arrow");
    977        }
    978      }
    979      let arrowPosition = "top";
    980      switch (positionParts[1]) {
    981        case "start":
    982        case "end": {
    983          // Inline arrow, i.e. arrow is on one of the left/right edges.
    984          let isRTL =
    985            this.ownerGlobal.getComputedStyle(this).direction === "rtl";
    986          let isRight = isRTL ^ (positionParts[1] === "start");
    987          let side = isRight ? "end" : "start";
    988          arrowPosition = `inline-${side}`;
    989          if (popupAlignment?.includes("center")) {
    990            arrowPosition = `inline-${side}`;
    991          } else if (positionParts[2] === "before") {
    992            arrowPosition = `inline-${side}-top`;
    993          } else if (positionParts[2] === "after") {
    994            arrowPosition = `inline-${side}-bottom`;
    995          }
    996          break;
    997        }
    998        case "before":
    999        case "after": {
   1000          // Block arrow, i.e. arrow is on one of the top/bottom edges.
   1001          let side = positionParts[1] === "before" ? "bottom" : "top";
   1002          arrowPosition = side;
   1003          if (popupAlignment?.includes("center")) {
   1004            arrowPosition = side;
   1005          } else if (positionParts[2] === "end") {
   1006            arrowPosition = `${side}-end`;
   1007          } else if (positionParts[2] === "start") {
   1008            arrowPosition = `${side}-start`;
   1009          }
   1010          break;
   1011        }
   1012      }
   1013      this.setAttribute("arrow-position", arrowPosition);
   1014    };
   1015  }
   1016 
   1017  _createContainer() {
   1018    const anchor = this._getAnchor();
   1019    // Don't render the callout if none of the anchors is visible.
   1020    if (!anchor) {
   1021      return false;
   1022    }
   1023 
   1024    const { autohide, ignorekeys, padding } = this.currentScreen.content;
   1025    const { panel_position, hide_arrow, no_open_on_anchor, arrow_width } =
   1026      anchor;
   1027    const needsPanel =
   1028      "MozXULElement" in this.win && !!panel_position?.panel_position_string;
   1029 
   1030    if (this._container) {
   1031      if (needsPanel ^ (this._container?.localName === "panel")) {
   1032        this._container.remove();
   1033      }
   1034    }
   1035 
   1036    if (!this._container?.parentElement) {
   1037      if (needsPanel) {
   1038        let fragment = this.win.MozXULElement.parseXULToFragment(`<panel
   1039            class="panel-no-padding"
   1040            orient="vertical"
   1041            noautofocus="true"
   1042            flip="slide"
   1043            type="arrow"
   1044            consumeoutsideclicks="never"
   1045            norolluponanchor="true"
   1046            position="${panel_position.panel_position_string}"
   1047            ${hide_arrow ? "" : 'show-arrow=""'}
   1048            ${autohide ? "" : 'noautohide="true"'}
   1049            ${ignorekeys ? 'ignorekeys="true"' : ""}
   1050            ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""}
   1051          />`);
   1052        this._container = fragment.firstElementChild;
   1053        this._setPanelMethods(this._container);
   1054      } else {
   1055        this._container = this.doc.createElement("div");
   1056        this._container?.classList.add("hidden");
   1057      }
   1058      this._container.classList.add("featureCallout");
   1059      if (hide_arrow) {
   1060        this._container.setAttribute("hide-arrow", "permanent");
   1061      } else {
   1062        this._container.removeAttribute("hide-arrow");
   1063      }
   1064      this._container.id = CONTAINER_ID;
   1065      this._container.setAttribute(
   1066        "aria-describedby",
   1067        "multi-stage-message-welcome-text"
   1068      );
   1069      if (arrow_width) {
   1070        this._container.style.setProperty("--arrow-width", `${arrow_width}px`);
   1071      } else {
   1072        this._container.style.removeProperty("--arrow-width");
   1073      }
   1074      if (padding) {
   1075        // This property used to accept a number value, either a number or a
   1076        // string that is a number. It now accepts a standard CSS padding value
   1077        // (e.g. "10px 12px" or "1em"), but we need to maintain backwards
   1078        // compatibility with the old number value until there are no more uses
   1079        // of it across experiments.
   1080        if (CSS.supports("padding", padding)) {
   1081          this._container.style.setProperty("--callout-padding", padding);
   1082        } else {
   1083          let cssValue = `${padding}px`;
   1084          if (CSS.supports("padding", cssValue)) {
   1085            this._container.style.setProperty("--callout-padding", cssValue);
   1086          } else {
   1087            this._container.style.removeProperty("--callout-padding");
   1088          }
   1089        }
   1090      } else {
   1091        this._container.style.removeProperty("--callout-padding");
   1092      }
   1093      let contentBox = this.doc.createElement("div");
   1094      contentBox.id = CONTENT_BOX_ID;
   1095      contentBox.classList.add("onboardingContainer");
   1096      // This value is reported as the "page" in about:welcome telemetry
   1097      contentBox.dataset.page = this.location;
   1098      this._applyTheme();
   1099      if (needsPanel && this.win.isChromeWindow) {
   1100        this.doc.getElementById("mainPopupSet").appendChild(this._container);
   1101      } else {
   1102        this.doc.body.prepend(this._container);
   1103      }
   1104      const makeArrow = classPrefix => {
   1105        const arrowRotationBox = this.doc.createElement("div");
   1106        arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`);
   1107        const arrow = this.doc.createElement("div");
   1108        arrow.classList.add("arrow", `${classPrefix}-arrow`);
   1109        arrowRotationBox.appendChild(arrow);
   1110        return arrowRotationBox;
   1111      };
   1112      this._container.appendChild(makeArrow("shadow"));
   1113      this._container.appendChild(contentBox);
   1114      this._container.appendChild(makeArrow("background"));
   1115    }
   1116    return this._container;
   1117  }
   1118 
   1119  /** @see HTMLArrowPosition */
   1120  _HTMLArrowPositions = [
   1121    "top",
   1122    "bottom",
   1123    "end",
   1124    "start",
   1125    "top-end",
   1126    "top-start",
   1127    "top-center-arrow-end",
   1128    "top-center-arrow-start",
   1129  ];
   1130 
   1131  /**
   1132   * Set callout's position relative to parent element
   1133   */
   1134  _positionCallout() {
   1135    const container = this._container;
   1136    const anchor = this._getAnchor();
   1137    if (!container || !anchor) {
   1138      this.endTour();
   1139      return;
   1140    }
   1141    const parentEl = anchor.element;
   1142    const { doc } = this;
   1143    const arrowPosition = anchor.arrow_position || "top";
   1144    const arrowWidth = anchor.arrow_width || 33.94113;
   1145    const arrowHeight = arrowWidth / 2;
   1146    const overlapAmount = 5;
   1147    let overlap = overlapAmount - arrowHeight;
   1148    // Is the document layout right to left?
   1149    const RTL = this.doc.dir === "rtl";
   1150    const customPosition = anchor.absolute_position;
   1151 
   1152    const getOffset = el => {
   1153      const rect = el.getBoundingClientRect();
   1154      return {
   1155        left: rect.left + this.win.scrollX,
   1156        right: rect.right + this.win.scrollX,
   1157        top: rect.top + this.win.scrollY,
   1158        bottom: rect.bottom + this.win.scrollY,
   1159      };
   1160    };
   1161 
   1162    const centerVertically = () => {
   1163      let topOffset =
   1164        (container.getBoundingClientRect().height -
   1165          parentEl.getBoundingClientRect().height) /
   1166        2;
   1167      container.style.top = `${getOffset(parentEl).top - topOffset}px`;
   1168    };
   1169 
   1170    /**
   1171     * Horizontally align a top/bottom-positioned callout according to the
   1172     * passed position.
   1173     *
   1174     * @param {string} position one of...
   1175     *   - "center": for use with top/bottom. arrow is in the center, and the
   1176     *       center of the callout aligns with the parent center.
   1177     *   - "center-arrow-start": for use with center-arrow-top-start. arrow is
   1178     *       on the start (left) side of the callout, and the callout is aligned
   1179     *       so that the arrow points to the center of the parent element.
   1180     *   - "center-arrow-end": for use with center-arrow-top-end. arrow is on
   1181     *       the end, and the arrow points to the center of the parent.
   1182     *   - "start": currently unused. align the callout's starting edge with the
   1183     *       parent's starting edge.
   1184     *   - "end": currently unused. same as start but for the ending edge.
   1185     */
   1186    const alignHorizontally = position => {
   1187      switch (position) {
   1188        case "center": {
   1189          const sideOffset =
   1190            (parentEl.getBoundingClientRect().width -
   1191              container.getBoundingClientRect().width) /
   1192            2;
   1193          const containerSide = RTL
   1194            ? doc.documentElement.clientWidth -
   1195              getOffset(parentEl).right +
   1196              sideOffset
   1197            : getOffset(parentEl).left + sideOffset;
   1198          container.style[RTL ? "right" : "left"] = `${Math.max(
   1199            containerSide,
   1200            0
   1201          )}px`;
   1202          break;
   1203        }
   1204        case "end":
   1205        case "start": {
   1206          const containerSide =
   1207            RTL ^ (position === "end")
   1208              ? parentEl.getBoundingClientRect().left +
   1209                parentEl.getBoundingClientRect().width -
   1210                container.getBoundingClientRect().width
   1211              : parentEl.getBoundingClientRect().left;
   1212          container.style.left = `${Math.max(containerSide, 0)}px`;
   1213          break;
   1214        }
   1215        case "center-arrow-end":
   1216        case "center-arrow-start": {
   1217          const parentRect = parentEl.getBoundingClientRect();
   1218          const containerWidth = container.getBoundingClientRect().width;
   1219          const containerSide =
   1220            RTL ^ position.endsWith("end")
   1221              ? parentRect.left +
   1222                parentRect.width / 2 +
   1223                12 +
   1224                arrowWidth / 2 -
   1225                containerWidth
   1226              : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2;
   1227          const maxContainerSide =
   1228            doc.documentElement.clientWidth - containerWidth;
   1229          container.style.left = `${Math.min(
   1230            maxContainerSide,
   1231            Math.max(containerSide, 0)
   1232          )}px`;
   1233        }
   1234      }
   1235    };
   1236 
   1237    // Remember not to use HTML-only properties/methods like offsetHeight. Try
   1238    // to use getBoundingClientRect() instead, which is available on XUL
   1239    // elements. This is necessary to support feature callout in chrome, which
   1240    // is still largely XUL-based.
   1241    const positioners = {
   1242      // availableSpace should be the space between the edge of the page in the
   1243      // assumed direction and the edge of the parent (with the callout being
   1244      // intended to fit between those two edges) while needed space should be
   1245      // the space necessary to fit the callout container.
   1246      top: {
   1247        availableSpace() {
   1248          return (
   1249            doc.documentElement.clientHeight -
   1250            getOffset(parentEl).top -
   1251            parentEl.getBoundingClientRect().height
   1252          );
   1253        },
   1254        neededSpace: container.getBoundingClientRect().height - overlap,
   1255        position() {
   1256          // Point to an element above the callout
   1257          let containerTop =
   1258            getOffset(parentEl).top +
   1259            parentEl.getBoundingClientRect().height -
   1260            overlap;
   1261          container.style.top = `${Math.max(0, containerTop)}px`;
   1262          alignHorizontally("center");
   1263        },
   1264      },
   1265      bottom: {
   1266        availableSpace() {
   1267          return getOffset(parentEl).top;
   1268        },
   1269        neededSpace: container.getBoundingClientRect().height - overlap,
   1270        position() {
   1271          // Point to an element below the callout
   1272          let containerTop =
   1273            getOffset(parentEl).top -
   1274            container.getBoundingClientRect().height +
   1275            overlap;
   1276          container.style.top = `${Math.max(0, containerTop)}px`;
   1277          alignHorizontally("center");
   1278        },
   1279      },
   1280      right: {
   1281        availableSpace() {
   1282          return getOffset(parentEl).left;
   1283        },
   1284        neededSpace: container.getBoundingClientRect().width - overlap,
   1285        position() {
   1286          // Point to an element to the right of the callout
   1287          let containerLeft =
   1288            getOffset(parentEl).left -
   1289            container.getBoundingClientRect().width +
   1290            overlap;
   1291          container.style.left = `${Math.max(0, containerLeft)}px`;
   1292          if (
   1293            container.getBoundingClientRect().height <=
   1294            parentEl.getBoundingClientRect().height
   1295          ) {
   1296            container.style.top = `${getOffset(parentEl).top}px`;
   1297          } else {
   1298            centerVertically();
   1299          }
   1300        },
   1301      },
   1302      left: {
   1303        availableSpace() {
   1304          return doc.documentElement.clientWidth - getOffset(parentEl).right;
   1305        },
   1306        neededSpace: container.getBoundingClientRect().width - overlap,
   1307        position() {
   1308          // Point to an element to the left of the callout
   1309          let containerLeft =
   1310            getOffset(parentEl).left +
   1311            parentEl.getBoundingClientRect().width -
   1312            overlap;
   1313          container.style.left = `${Math.max(0, containerLeft)}px`;
   1314          if (
   1315            container.getBoundingClientRect().height <=
   1316            parentEl.getBoundingClientRect().height
   1317          ) {
   1318            container.style.top = `${getOffset(parentEl).top}px`;
   1319          } else {
   1320            centerVertically();
   1321          }
   1322        },
   1323      },
   1324      "top-start": {
   1325        availableSpace() {
   1326          return (
   1327            doc.documentElement.clientHeight -
   1328            getOffset(parentEl).top -
   1329            parentEl.getBoundingClientRect().height
   1330          );
   1331        },
   1332        neededSpace: container.getBoundingClientRect().height - overlap,
   1333        position() {
   1334          // Point to an element above and at the start of the callout
   1335          let containerTop =
   1336            getOffset(parentEl).top +
   1337            parentEl.getBoundingClientRect().height -
   1338            overlap;
   1339          container.style.top = `${Math.max(0, containerTop)}px`;
   1340          alignHorizontally("start");
   1341        },
   1342      },
   1343      "top-end": {
   1344        availableSpace() {
   1345          return (
   1346            doc.documentElement.clientHeight -
   1347            getOffset(parentEl).top -
   1348            parentEl.getBoundingClientRect().height
   1349          );
   1350        },
   1351        neededSpace: container.getBoundingClientRect().height - overlap,
   1352        position() {
   1353          // Point to an element above and at the end of the callout
   1354          let containerTop =
   1355            getOffset(parentEl).top +
   1356            parentEl.getBoundingClientRect().height -
   1357            overlap;
   1358          container.style.top = `${Math.max(0, containerTop)}px`;
   1359          alignHorizontally("end");
   1360        },
   1361      },
   1362      "top-center-arrow-start": {
   1363        availableSpace() {
   1364          return (
   1365            doc.documentElement.clientHeight -
   1366            getOffset(parentEl).top -
   1367            parentEl.getBoundingClientRect().height
   1368          );
   1369        },
   1370        neededSpace: container.getBoundingClientRect().height - overlap,
   1371        position() {
   1372          // Point to an element above and at the start of the callout
   1373          let containerTop =
   1374            getOffset(parentEl).top +
   1375            parentEl.getBoundingClientRect().height -
   1376            overlap;
   1377          container.style.top = `${Math.max(0, containerTop)}px`;
   1378          alignHorizontally("center-arrow-start");
   1379        },
   1380      },
   1381      "top-center-arrow-end": {
   1382        availableSpace() {
   1383          return (
   1384            doc.documentElement.clientHeight -
   1385            getOffset(parentEl).top -
   1386            parentEl.getBoundingClientRect().height
   1387          );
   1388        },
   1389        neededSpace: container.getBoundingClientRect().height - overlap,
   1390        position() {
   1391          // Point to an element above and at the end of the callout
   1392          let containerTop =
   1393            getOffset(parentEl).top +
   1394            parentEl.getBoundingClientRect().height -
   1395            overlap;
   1396          container.style.top = `${Math.max(0, containerTop)}px`;
   1397          alignHorizontally("center-arrow-end");
   1398        },
   1399      },
   1400    };
   1401 
   1402    const clearPosition = () => {
   1403      Object.keys(positioners).forEach(position => {
   1404        container.style[position] = "unset";
   1405      });
   1406      container.removeAttribute("arrow-position");
   1407    };
   1408 
   1409    const setArrowPosition = position => {
   1410      let val;
   1411      switch (position) {
   1412        case "bottom":
   1413          val = "bottom";
   1414          break;
   1415        case "left":
   1416          val = "inline-start";
   1417          break;
   1418        case "right":
   1419          val = "inline-end";
   1420          break;
   1421        case "top-start":
   1422        case "top-center-arrow-start":
   1423          val = RTL ? "top-end" : "top-start";
   1424          break;
   1425        case "top-end":
   1426        case "top-center-arrow-end":
   1427          val = RTL ? "top-start" : "top-end";
   1428          break;
   1429        case "top":
   1430        default:
   1431          val = "top";
   1432          break;
   1433      }
   1434 
   1435      container.setAttribute("arrow-position", val);
   1436    };
   1437 
   1438    const addValueToPixelValue = (value, pixelValue) => {
   1439      return `${parseFloat(pixelValue) + value}px`;
   1440    };
   1441 
   1442    const subtractPixelValueFromValue = (pixelValue, value) => {
   1443      return `${value - parseFloat(pixelValue)}px`;
   1444    };
   1445 
   1446    const overridePosition = () => {
   1447      // We override _every_ positioner here, because we want to manually set
   1448      // all container.style.positions in every positioner's "position" function
   1449      // regardless of the actual arrow position
   1450 
   1451      // Note: We override the position functions with new functions here, but
   1452      // they don't actually get executed until the respective position
   1453      // functions are called and this function is not executed unless the
   1454      // message has a custom position property.
   1455 
   1456      // We're positioning relative to a parent element's bounds, if that parent
   1457      // element exists.
   1458 
   1459      for (const position in positioners) {
   1460        if (!Object.prototype.hasOwnProperty.call(positioners, position)) {
   1461          continue;
   1462        }
   1463 
   1464        positioners[position].position = () => {
   1465          if (customPosition.top) {
   1466            container.style.top = addValueToPixelValue(
   1467              parentEl.getBoundingClientRect().top,
   1468              customPosition.top
   1469            );
   1470          }
   1471 
   1472          if (customPosition.left) {
   1473            const leftPosition = addValueToPixelValue(
   1474              parentEl.getBoundingClientRect().left,
   1475              customPosition.left
   1476            );
   1477 
   1478            if (RTL) {
   1479              container.style.right = leftPosition;
   1480            } else {
   1481              container.style.left = leftPosition;
   1482            }
   1483          }
   1484 
   1485          if (customPosition.right) {
   1486            const rightPosition = subtractPixelValueFromValue(
   1487              customPosition.right,
   1488              parentEl.getBoundingClientRect().right -
   1489                container.getBoundingClientRect().width
   1490            );
   1491 
   1492            if (RTL) {
   1493              container.style.right = rightPosition;
   1494            } else {
   1495              container.style.left = rightPosition;
   1496            }
   1497          }
   1498 
   1499          if (customPosition.bottom) {
   1500            container.style.top = subtractPixelValueFromValue(
   1501              customPosition.bottom,
   1502              parentEl.getBoundingClientRect().bottom -
   1503                container.getBoundingClientRect().height
   1504            );
   1505          }
   1506        };
   1507      }
   1508    };
   1509 
   1510    const calloutFits = position => {
   1511      // Does callout element fit in this position relative
   1512      // to the parent element without going off screen?
   1513 
   1514      // Only consider which edge of the callout the arrow points from,
   1515      // not the alignment of the arrow along the edge of the callout
   1516      let [edgePosition] = position.split("-");
   1517      return (
   1518        positioners[edgePosition].availableSpace() >
   1519        positioners[edgePosition].neededSpace
   1520      );
   1521    };
   1522 
   1523    const choosePosition = () => {
   1524      let position = arrowPosition;
   1525      if (!this._HTMLArrowPositions.includes(position)) {
   1526        // Configured arrow position is not valid
   1527        position = null;
   1528      }
   1529      if (["start", "end"].includes(position)) {
   1530        // position here is referencing the direction that the callout container
   1531        // is pointing to, and therefore should be the _opposite_ side of the
   1532        // arrow eg. if arrow is at the "end" in LTR layouts, the container is
   1533        // pointing at an element to the right of itself, while in RTL layouts
   1534        // it is pointing to the left of itself
   1535        position = RTL ^ (position === "start") ? "left" : "right";
   1536      }
   1537      // If we're overriding the position, we don't need to sort for available space
   1538      if (customPosition || (position && calloutFits(position))) {
   1539        return position;
   1540      }
   1541      let sortedPositions = ["top", "bottom", "left", "right"]
   1542        .filter(p => p !== position)
   1543        .filter(calloutFits)
   1544        .sort((a, b) => {
   1545          return (
   1546            positioners[b].availableSpace() - positioners[b].neededSpace >
   1547            positioners[a].availableSpace() - positioners[a].neededSpace
   1548          );
   1549        });
   1550      // If the callout doesn't fit in any position, use the configured one.
   1551      // The callout will be adjusted to overlap the parent element so that
   1552      // the former doesn't go off screen.
   1553      return sortedPositions[0] || position;
   1554    };
   1555 
   1556    clearPosition(container);
   1557 
   1558    if (customPosition) {
   1559      overridePosition();
   1560    }
   1561 
   1562    let finalPosition = choosePosition();
   1563    if (finalPosition) {
   1564      positioners[finalPosition].position();
   1565      setArrowPosition(finalPosition);
   1566    }
   1567 
   1568    container.classList.remove("hidden");
   1569  }
   1570 
   1571  /** Expose top level functions expected by the aboutwelcome bundle. */
   1572  _setupWindowFunctions() {
   1573    if (this.AWSetup) {
   1574      return;
   1575    }
   1576 
   1577    const handleActorMessage =
   1578      lazy.AboutWelcomeParent.prototype.onContentMessage.bind({});
   1579    const getActionHandler = name => data =>
   1580      handleActorMessage(`AWPage:${name}`, data, this.doc);
   1581 
   1582    const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT");
   1583    const AWSendEventTelemetry = data => {
   1584      if (this.config?.metrics !== "block") {
   1585        return telemetryMessageHandler(data);
   1586      }
   1587      return null;
   1588    };
   1589    this._windowFuncs = {
   1590      AWGetFeatureConfig: () => this.config,
   1591      AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"),
   1592      AWGetInstalledAddons: getActionHandler("GET_INSTALLED_ADDONS"),
   1593      // Do not send telemetry if message config sets metrics as 'block'.
   1594      AWSendEventTelemetry,
   1595      AWSendToDeviceEmailsSupported: getActionHandler(
   1596        "SEND_TO_DEVICE_EMAILS_SUPPORTED"
   1597      ),
   1598      AWSendToParent: (name, data) => getActionHandler(name)(data),
   1599      AWFinish: () => this.endTour(),
   1600      AWAdvanceScreens: options => this._advanceScreens(options),
   1601      AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"),
   1602      AWEvaluateAttributeTargeting: getActionHandler(
   1603        "EVALUATE_ATTRIBUTE_TARGETING"
   1604      ),
   1605    };
   1606    for (const [name, func] of Object.entries(this._windowFuncs)) {
   1607      this.win[name] = func;
   1608    }
   1609 
   1610    this.AWSetup = true;
   1611  }
   1612 
   1613  /** Clean up the functions defined above. */
   1614  _clearWindowFunctions() {
   1615    if (this.AWSetup) {
   1616      this.AWSetup = false;
   1617 
   1618      for (const name of Object.keys(this._windowFuncs)) {
   1619        delete this.win[name];
   1620      }
   1621    }
   1622  }
   1623 
   1624  /**
   1625   * Emit an event to the broker, if one is present.
   1626   *
   1627   * @param {string} name
   1628   * @param {any} data
   1629   */
   1630  _emitEvent(name, data) {
   1631    this.listener?.(this.win, name, data);
   1632  }
   1633 
   1634  endTour(skipFadeOut = false) {
   1635    // We don't want focus events that happen during teardown to affect
   1636    // this.savedFocus
   1637    this.win.removeEventListener("focus", this, {
   1638      capture: true,
   1639      passive: true,
   1640    });
   1641    this.win.removeEventListener("keypress", this, { capture: true });
   1642    this._pageEventManager?.emit({
   1643      type: "tourend",
   1644      target: this._container,
   1645    });
   1646    this._container?.removeEventListener("popuphiding", this);
   1647    this._pageEventManager?.clear();
   1648 
   1649    // Delete almost everything to get this ready to show a different message.
   1650    this.teardownFeatureTourProgress();
   1651    this.pref = null;
   1652    this.ready = false;
   1653    this.message = null;
   1654    this.content = null;
   1655    this.currentScreen = null;
   1656    // wait for fade out transition
   1657    this._container?.classList.toggle(
   1658      "hidden",
   1659      this._container?.localName !== "panel"
   1660    );
   1661    this._clearWindowFunctions();
   1662    const onFadeOut = () => {
   1663      this._container?.remove();
   1664      this.renderObserver?.disconnect();
   1665      this._removePositionListeners();
   1666      this._removePanelConflictListeners();
   1667      this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
   1668      // Put the focus back to the last place the user focused outside of the
   1669      // featureCallout windows.
   1670      if (this.savedFocus) {
   1671        this.savedFocus.element.focus({
   1672          focusVisible: this.savedFocus.focusVisible,
   1673        });
   1674      }
   1675      this.savedFocus = null;
   1676      this._emitEvent("end");
   1677    };
   1678    if (this._container?.localName === "panel") {
   1679      const controller = new AbortController();
   1680      this._container.addEventListener(
   1681        "popuphidden",
   1682        event => {
   1683          if (event.target === this._container) {
   1684            controller.abort();
   1685            onFadeOut();
   1686          }
   1687        },
   1688        { signal: controller.signal }
   1689      );
   1690      this._container.hidePopup(!skipFadeOut);
   1691    } else if (this._container) {
   1692      this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS);
   1693    } else {
   1694      onFadeOut();
   1695    }
   1696  }
   1697 
   1698  _dismiss() {
   1699    let action =
   1700      this.currentScreen?.content.dismiss_action ??
   1701      this.currentScreen?.content.dismiss_button?.action;
   1702    if (action?.type) {
   1703      this.win.AWSendToParent("SPECIAL_ACTION", action);
   1704      if (!action.dismiss) {
   1705        return;
   1706      }
   1707    }
   1708    this.endTour();
   1709  }
   1710 
   1711  async _addScriptsAndRender() {
   1712    const reactSrc = "chrome://global/content/vendor/react.js";
   1713    const domSrc = "chrome://global/content/vendor/react-dom.js";
   1714    // Add React script
   1715    const getReactReady = () => {
   1716      return new Promise(resolve => {
   1717        let reactScript = this.doc.createElement("script");
   1718        reactScript.src = reactSrc;
   1719        this.doc.head.appendChild(reactScript);
   1720        reactScript.addEventListener("load", resolve);
   1721      });
   1722    };
   1723    // Add ReactDom script
   1724    const getDomReady = () => {
   1725      return new Promise(resolve => {
   1726        let domScript = this.doc.createElement("script");
   1727        domScript.src = domSrc;
   1728        this.doc.head.appendChild(domScript);
   1729        domScript.addEventListener("load", resolve);
   1730      });
   1731    };
   1732    // Load React, then React Dom
   1733    if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
   1734      await getReactReady();
   1735    }
   1736    if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
   1737      await getDomReady();
   1738    }
   1739    // Load the bundle to render the content as configured.
   1740    this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
   1741    let bundleScript = this.doc.createElement("script");
   1742    bundleScript.src = BUNDLE_SRC;
   1743    this.doc.head.appendChild(bundleScript);
   1744  }
   1745 
   1746  _observeRender(container) {
   1747    this.renderObserver?.observe(container, { childList: true });
   1748  }
   1749 
   1750  /**
   1751   * Update the internal config with a new message. If a message is not
   1752   * provided, try requesting one from ASRouter. The message content is stored
   1753   * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome
   1754   * bundle will use that function to get the content when it executes.
   1755   *
   1756   * @param {object} [message] ASRouter message. Omit to request a new one.
   1757   * @param {number} [screenIndex] Index of the screen to render.
   1758   * @returns {Promise<boolean>} true if a message is loaded, false if not.
   1759   */
   1760  async _updateConfig(message, screenIndex) {
   1761    if (this.loadingConfig) {
   1762      return false;
   1763    }
   1764 
   1765    this.message = structuredClone(message || (await this._loadConfig()));
   1766 
   1767    switch (this.message.template) {
   1768      case "feature_callout":
   1769        break;
   1770      case "spotlight":
   1771        // Deprecated: Special handling for spotlight messages, used as an
   1772        // introduction to feature tours.
   1773        this.currentScreen = "spotlight";
   1774      // fall through
   1775      default:
   1776        return false;
   1777    }
   1778 
   1779    this.config = this.message.content;
   1780 
   1781    if (!this.config.screens) {
   1782      lazy.log.error(
   1783        `In ${
   1784          this.location
   1785        }: Expected a message object with content.screens property, got: ${JSON.stringify(
   1786          this.message
   1787        )}`
   1788      );
   1789      return false;
   1790    }
   1791 
   1792    // Set or override the default start screen.
   1793    let overrideScreen = Number.isInteger(screenIndex);
   1794    if (overrideScreen) {
   1795      this.config.startScreen = screenIndex;
   1796    }
   1797 
   1798    let newScreen = this.config?.screens?.[this.config?.startScreen ?? 0];
   1799 
   1800    // If we have a feature tour in progress, try to set the start screen to
   1801    // whichever screen is configured in the feature tour pref.
   1802    if (
   1803      !overrideScreen &&
   1804      this.config?.tour_pref_name &&
   1805      this.config.tour_pref_name === this.pref?.name &&
   1806      this.featureTourProgress
   1807    ) {
   1808      const newIndex = this.config.screens.findIndex(
   1809        screen => screen.id === this.featureTourProgress.screen
   1810      );
   1811      if (newIndex !== -1) {
   1812        newScreen = this.config.screens[newIndex];
   1813        if (newScreen?.id !== this.currentScreen?.id) {
   1814          // This is how we tell the bundle to render the correct screen.
   1815          this.config.startScreen = newIndex;
   1816        }
   1817      }
   1818    }
   1819    if (newScreen?.id === this.currentScreen?.id) {
   1820      return false;
   1821    }
   1822 
   1823    this.currentScreen = newScreen;
   1824    return true;
   1825  }
   1826 
   1827  /**
   1828   * Request a message from ASRouter, targeting the `browser` and `page` values
   1829   * passed to the constructor.
   1830   *
   1831   * @returns {Promise<object>} the requested message.
   1832   */
   1833  async _loadConfig() {
   1834    this.loadingConfig = true;
   1835    await lazy.ASRouter.waitForInitialized;
   1836    let result = await lazy.ASRouter.sendTriggerMessage({
   1837      browser: this.browser,
   1838      // triggerId and triggerContext
   1839      id: "featureCalloutCheck",
   1840      context: { source: this.location },
   1841    });
   1842    this.loadingConfig = false;
   1843    return result.message;
   1844  }
   1845 
   1846  /**
   1847   * Try to render the callout in the current document.
   1848   *
   1849   * @returns {Promise<boolean>} whether the callout was rendered.
   1850   */
   1851  async _renderCallout() {
   1852    this._setupWindowFunctions();
   1853    await this._addCalloutLinkElements();
   1854    let container = this._createContainer();
   1855    if (container) {
   1856      // This results in rendering the Feature Callout
   1857      await this._addScriptsAndRender();
   1858      this._observeRender(container.querySelector(`#${CONTENT_BOX_ID}`));
   1859      if (container.localName === "div") {
   1860        this._addPositionListeners();
   1861      }
   1862      return true;
   1863    }
   1864    return false;
   1865  }
   1866 
   1867  /**
   1868   * For each member of the screen's page_event_listeners array, add a listener.
   1869   *
   1870   * @param {Array<PageEventListenerConfig>} listeners
   1871   *
   1872   * @typedef {object} PageEventListenerConfig
   1873   * @property {PageEventListenerParams} params Event listener parameters
   1874   * @property {PageEventListenerAction} action Sent when the event fires
   1875   *
   1876   * @typedef {object} PageEventListenerParams See PageEventManager.sys.mjs
   1877   * @property {string} type Event type string e.g. `click`
   1878   * @property {string} [selectors] Target selector, e.g. `tag.class, #id[attr]`
   1879   * @property {PageEventListenerOptions} [options] addEventListener options
   1880   *
   1881   * @typedef {object} PageEventListenerOptions
   1882   * @property {boolean} [capture] Use event capturing phase
   1883   * @property {boolean} [once] Remove listener after first event
   1884   * @property {boolean} [preventDefault] Prevent default action
   1885   * @property {number} [interval] Used only for `timeout` and `interval` event
   1886   *   types. These don't set up real event listeners, but instead invoke the
   1887   *   action on a timer.
   1888   * @property {boolean} [every_window] Extend addEventListener to all windows.
   1889   *   Not compatible with `interval`.
   1890   *
   1891   * @typedef {object} PageEventListenerAction Action sent to AboutWelcomeParent
   1892   * @property {string} [type] Action type, e.g. `OPEN_URL`
   1893   * @property {object} [data] Extra data, properties depend on action type
   1894   * @property {AdvanceScreensOptions} [advance_screens] Jump to a new screen
   1895   * @property {boolean | "actionResult"} [dismiss] Dismiss callout
   1896   * @property {boolean | "actionResult"} [reposition] Reposition callout
   1897   * @property {boolean} [needsAwait] Wait for any special message actions
   1898   *   (given by the type property above) to resolve before advancing screens,
   1899   *   dismissing, or repositioning the callout, if those actions are set to
   1900   *   "actionResult".
   1901   */
   1902  _attachPageEventListeners(listeners) {
   1903    listeners?.forEach(({ params, action }) =>
   1904      this._loadPageEventManager[params.options?.once ? "once" : "on"](
   1905        params,
   1906        event => {
   1907          this._handlePageEventAction(action, event);
   1908          if (params.options?.preventDefault) {
   1909            event.preventDefault?.();
   1910          }
   1911        }
   1912      )
   1913    );
   1914  }
   1915 
   1916  /**
   1917   * Perform an action in response to a page event.
   1918   *
   1919   * @param {PageEventListenerAction} action
   1920   * @param {Event} event Triggering event
   1921   */
   1922  async _handlePageEventAction(action, event) {
   1923    const page = this.location;
   1924    const message_id = this.config?.id.toUpperCase();
   1925    const source =
   1926      typeof event.target === "string"
   1927        ? event.target
   1928        : this._getUniqueElementIdentifier(event.target);
   1929    let actionResult;
   1930    if (action.type) {
   1931      this.win.AWSendEventTelemetry?.({
   1932        event: "PAGE_EVENT",
   1933        event_context: {
   1934          action: action.type,
   1935          reason: event.type?.toUpperCase(),
   1936          source,
   1937          page,
   1938        },
   1939        message_id,
   1940      });
   1941      let actionPromise = this.win.AWSendToParent("SPECIAL_ACTION", action);
   1942      if (action.needsAwait) {
   1943        actionResult = await actionPromise;
   1944      }
   1945    }
   1946 
   1947    // `navigate` and `dismiss` can be true/false/undefined, or they can be a
   1948    // string "actionResult" in which case we should use the actionResult
   1949    // (boolean resolved by handleUserAction)
   1950    const shouldDoBehavior = behavior => {
   1951      if (behavior !== "actionResult") {
   1952        return behavior;
   1953      }
   1954      if (action.needsAwait) {
   1955        return actionResult;
   1956      }
   1957      lazy.log.warn(
   1958        `In ${
   1959          this.location
   1960        }: "actionResult" is only supported for actions with needsAwait, got: ${JSON.stringify(action)}`
   1961      );
   1962      return false;
   1963    };
   1964 
   1965    if (action.advance_screens) {
   1966      if (shouldDoBehavior(action.advance_screens.behavior ?? true)) {
   1967        this._advanceScreens?.(action.advance_screens);
   1968      }
   1969    }
   1970    if (shouldDoBehavior(action.dismiss)) {
   1971      this.win.AWSendEventTelemetry?.({
   1972        event: "DISMISS",
   1973        event_context: { source: `PAGE_EVENT:${source}`, page },
   1974        message_id,
   1975      });
   1976      this._dismiss();
   1977    }
   1978    if (shouldDoBehavior(action.reposition)) {
   1979      this.win.requestAnimationFrame(() => this._positionCallout());
   1980    }
   1981  }
   1982 
   1983  /**
   1984   * For a given element, calculate a unique string that identifies it.
   1985   *
   1986   * @param {Element} target Element to calculate the selector for
   1987   * @returns {string} Computed event target selector, e.g. `button#next`
   1988   */
   1989  _getUniqueElementIdentifier(target) {
   1990    let source;
   1991    if (Element.isInstance(target)) {
   1992      source = target.localName;
   1993      if (target.className) {
   1994        source += `.${[...target.classList].join(".")}`;
   1995      }
   1996      if (target.id) {
   1997        source += `#${target.id}`;
   1998      }
   1999      if (target.attributes.length) {
   2000        source += `${[...target.attributes]
   2001          .filter(attr => ["is", "role", "open"].includes(attr.name))
   2002          .map(attr => `[${attr.name}="${attr.value}"]`)
   2003          .join("")}`;
   2004      }
   2005      let doc = target.ownerDocument;
   2006      if (doc.querySelectorAll(source).length > 1) {
   2007        let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
   2008        if (uniqueAncestor) {
   2009          source = `${this._getUniqueElementIdentifier(
   2010            uniqueAncestor
   2011          )} > ${source}`;
   2012        }
   2013      }
   2014      if (doc !== this.doc) {
   2015        let windowIndex = [
   2016          ...Services.wm.getEnumerator("navigator:browser"),
   2017        ].indexOf(target.ownerGlobal);
   2018        source = `window${windowIndex + 1}: ${source}`;
   2019      }
   2020    }
   2021    return source;
   2022  }
   2023 
   2024  /**
   2025   * Get the element that should be autofocused when the callout first opens. By
   2026   * default, prioritize the primary button, then the secondary button, then any
   2027   * additional button, excluding pseudo-links and the dismiss button. If no
   2028   * button is found, focus the first input element. If no affirmative action is
   2029   * found, focus the first button, which is probably the dismiss button. A
   2030   * custom selector can also be provided to focus a specific element.
   2031   *
   2032   * @param {AutoFocusOptions} [options]
   2033   * @returns {Element|null} The element to focus when the callout is shown.
   2034   */
   2035  getAutoFocusElement({ selector, use_defaults = true } = {}) {
   2036    if (!this._container) {
   2037      return null;
   2038    }
   2039    if (selector) {
   2040      let element = this._container.querySelector(selector);
   2041      if (element) {
   2042        return element;
   2043      }
   2044    }
   2045    if (use_defaults) {
   2046      return (
   2047        this._container.querySelector(
   2048          ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
   2049        ) ||
   2050        this._container.querySelector(
   2051          ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
   2052        ) ||
   2053        this._container.querySelector(
   2054          "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)"
   2055        ) ||
   2056        this._container.querySelector("input:not(:disabled, [hidden])") ||
   2057        this._container.querySelector(
   2058          "button:not(:disabled, [hidden], .text-link, .cta-link)"
   2059        )
   2060      );
   2061    }
   2062    return null;
   2063  }
   2064 
   2065  /**
   2066   * Show a feature callout message, either by requesting one from ASRouter or
   2067   * by showing a message passed as an argument.
   2068   *
   2069   * @param {object} [message] optional message to show instead of requesting one
   2070   * @returns {Promise<boolean>} true if a message was shown
   2071   */
   2072  async showFeatureCallout(message) {
   2073    let updated = await this._updateConfig(message);
   2074 
   2075    if (!updated || !this.config?.screens?.length) {
   2076      return !!this.currentScreen;
   2077    }
   2078 
   2079    if (!this.renderObserver) {
   2080      this.renderObserver = new this.win.MutationObserver(() => {
   2081        // Check if the Feature Callout screen has loaded for the first time
   2082        if (!this.ready && this._container.querySelector(".screen")) {
   2083          const anchor = this._getAnchor();
   2084          const onRender = () => {
   2085            this.ready = true;
   2086            this._pageEventManager?.clear();
   2087            this._attachPageEventListeners(
   2088              this.currentScreen?.content?.page_event_listeners
   2089            );
   2090            if (anchor?.autofocus) {
   2091              this.getAutoFocusElement(anchor.autofocus)?.focus();
   2092            }
   2093            this.win.addEventListener("keypress", this, { capture: true });
   2094            if (this._container.localName === "div") {
   2095              this.win.addEventListener("focus", this, {
   2096                capture: true, // get the event before retargeting
   2097                passive: true,
   2098              });
   2099              this._positionCallout();
   2100            } else {
   2101              this._container.classList.remove("hidden");
   2102            }
   2103          };
   2104          if (
   2105            this._container.localName === "div" &&
   2106            this.doc.activeElement &&
   2107            !this.savedFocus
   2108          ) {
   2109            let element = this.doc.activeElement;
   2110            this.savedFocus = {
   2111              element,
   2112              focusVisible: element.matches(":focus-visible"),
   2113            };
   2114          }
   2115          // Once the screen element is added to the DOM, wait for the
   2116          // animation frame after next to ensure that _positionCallout
   2117          // has access to the rendered screen with the correct height
   2118          if (this._container.localName === "div") {
   2119            this.win.requestAnimationFrame(() => {
   2120              this.win.requestAnimationFrame(onRender);
   2121            });
   2122          } else if (this._container.localName === "panel") {
   2123            if (!anchor?.panel_position) {
   2124              this.endTour();
   2125              return;
   2126            }
   2127            const {
   2128              panel_position_string: position,
   2129              offset_x: x,
   2130              offset_y: y,
   2131            } = anchor.panel_position;
   2132            this._container.addEventListener("popupshown", onRender, {
   2133              once: true,
   2134            });
   2135            this._container.addEventListener("popuphiding", this);
   2136            this._addPanelConflictListeners();
   2137            this._container.openPopup(anchor.element, { position, x, y });
   2138          }
   2139        }
   2140      });
   2141    }
   2142 
   2143    this._pageEventManager?.clear();
   2144    this.ready = false;
   2145    this._container?.remove();
   2146    this.renderObserver?.disconnect();
   2147 
   2148    let rendering = (await this._renderCallout()) && !!this.currentScreen;
   2149    if (!rendering) {
   2150      this.endTour();
   2151    }
   2152 
   2153    if (this.message.template) {
   2154      lazy.ASRouter.addImpression(this.message);
   2155    }
   2156    return rendering;
   2157  }
   2158 
   2159  /**
   2160   * @typedef {object} FeatureCalloutTheme An object with a set of custom color
   2161   *   schemes and/or a preset key. If both are provided, the preset will be
   2162   *   applied first, then the custom themes will override the preset values.
   2163   * @property {string} [preset] Key of {@link FeatureCallout.themePresets}
   2164   * @property {ColorScheme} [light] Custom light scheme
   2165   * @property {ColorScheme} [dark] Custom dark scheme
   2166   * @property {ColorScheme} [hcm] Custom high contrast scheme
   2167   * @property {ColorScheme} [all] Custom scheme that will be applied in all
   2168   *   cases, but overridden by the other schemes if they are present. This is
   2169   *   useful if the values are already controlled by the browser theme.
   2170   * @property {boolean} [simulateContent] Set to true if the feature callout
   2171   *   exists in the browser chrome but is meant to be displayed over the
   2172   *   content area to appear as if it is part of the page. This will cause the
   2173   *   styles to use a media query targeting the content instead of the chrome,
   2174   *   so that if the browser theme doesn't match the content color scheme, the
   2175   *   callout will correctly follow the content scheme. This is currently used
   2176   *   for the feature callouts displayed over the PDF.js viewer.
   2177   */
   2178 
   2179  /**
   2180   * @typedef {object} ColorScheme An object with key-value pairs, with keys
   2181   *   from {@link FeatureCallout.themePropNames}, mapped to CSS color values
   2182   */
   2183 
   2184  /**
   2185   * Combine the preset and custom themes into a single object and store it.
   2186   *
   2187   * @param {FeatureCalloutTheme} theme
   2188   */
   2189  _initTheme(theme) {
   2190    /** @type {FeatureCalloutTheme} */
   2191    this.theme = Object.assign(
   2192      {},
   2193      FeatureCallout.themePresets[theme.preset],
   2194      theme
   2195    );
   2196  }
   2197 
   2198  /**
   2199   * Apply all the theme colors to the feature callout's root element as CSS
   2200   * custom properties in inline styles. These custom properties are consumed by
   2201   * _feature-callout-theme.scss, which is bundled with the other styles that
   2202   * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}.
   2203   */
   2204  _applyTheme() {
   2205    if (this._container) {
   2206      // This tells the stylesheets to use -moz-content-prefers-color-scheme
   2207      // instead of prefers-color-scheme, in order to follow the content color
   2208      // scheme instead of the chrome color scheme, in case of a mismatch when
   2209      // the feature callout exists in the chrome but is meant to look like it's
   2210      // part of the content of a page in a browser tab (like PDF.js).
   2211      this._container.classList.toggle(
   2212        "simulateContent",
   2213        !!this.theme.simulateContent
   2214      );
   2215      this._container.classList.toggle(
   2216        "lwtNewtab",
   2217        !!(
   2218          this.theme.lwtNewtab !== false &&
   2219          this.theme.simulateContent &&
   2220          ["themed-content", "newtab"].includes(this.theme.preset)
   2221        )
   2222      );
   2223      for (const type of ["light", "dark", "hcm"]) {
   2224        const scheme = this.theme[type];
   2225        for (const name of FeatureCallout.themePropNames) {
   2226          this._setThemeVariable(
   2227            `--fc-${name}-${type}`,
   2228            scheme?.[name] || this.theme.all?.[name]
   2229          );
   2230        }
   2231      }
   2232    }
   2233  }
   2234 
   2235  /**
   2236   * Set or remove a CSS custom property on the feature callout container
   2237   *
   2238   * @param {string} name Name of the CSS custom property
   2239   * @param {string | void} [value] Value of the property, or omit to remove it
   2240   */
   2241  _setThemeVariable(name, value) {
   2242    if (value) {
   2243      this._container.style.setProperty(name, value);
   2244    } else {
   2245      this._container.style.removeProperty(name);
   2246    }
   2247  }
   2248 
   2249  /** A list of all the theme properties that can be set */
   2250  static themePropNames = [
   2251    "background",
   2252    "color",
   2253    "border",
   2254    "accent-color",
   2255    "button-background",
   2256    "button-color",
   2257    "button-border",
   2258    "button-background-hover",
   2259    "button-color-hover",
   2260    "button-border-hover",
   2261    "button-background-active",
   2262    "button-color-active",
   2263    "button-border-active",
   2264    "primary-button-background",
   2265    "primary-button-color",
   2266    "primary-button-border",
   2267    "primary-button-background-hover",
   2268    "primary-button-color-hover",
   2269    "primary-button-border-hover",
   2270    "primary-button-background-active",
   2271    "primary-button-color-active",
   2272    "primary-button-border-active",
   2273    "link-color",
   2274    "link-color-hover",
   2275    "link-color-active",
   2276    "icon-success-color",
   2277    "dismiss-button-background",
   2278    "dismiss-button-background-hover",
   2279    "dismiss-button-background-active",
   2280  ];
   2281 
   2282  /** @type {{[key: string]: FeatureCalloutTheme}} */
   2283  static themePresets = {
   2284    // For themed system pages like New Tab and Firefox View. Themed content
   2285    // colors inherit from the user's theme through contentTheme.js.
   2286    "themed-content": {
   2287      all: {
   2288        background:
   2289          "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(var(--newtab-background-color-secondary), var(--newtab-background-color-secondary))",
   2290        color: "var(--newtab-text-primary-color, var(--text-color))",
   2291        border:
   2292          "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)",
   2293        "accent-color": "var(--button-background-color-primary)",
   2294        "button-background": "color-mix(in srgb, transparent 93%, #000)",
   2295        "button-color": "var(--newtab-text-primary-color, var(--text-color))",
   2296        "button-border": "transparent",
   2297        "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
   2298        "button-color-hover":
   2299          "var(--newtab-text-primary-color, var(--text-color))",
   2300        "button-border-hover": "transparent",
   2301        "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
   2302        "button-color-active":
   2303          "var(--newtab-text-primary-color, var(--text-color))",
   2304        "button-border-active": "transparent",
   2305        "primary-button-background": "var(--button-background-color-primary)",
   2306        "primary-button-color": "var(--button-text-color-primary)",
   2307        "primary-button-border": "var(--button-border-color-primary)",
   2308        "primary-button-background-hover":
   2309          "var(--button-background-color-primary-hover)",
   2310        "primary-button-color-hover": "var(--button-text-color-primary-hover)",
   2311        "primary-button-border-hover":
   2312          "var(--button-border-color-primary-hover)",
   2313        "primary-button-background-active":
   2314          "var(--button-background-color-primary-active)",
   2315        "primary-button-color-active":
   2316          "var(--button-text-color-primary-active)",
   2317        "primary-button-border-active":
   2318          "var(--button-border-color-primary-active)",
   2319        "link-color": "LinkText",
   2320        "link-color-hover": "LinkText",
   2321        "link-color-active": "ActiveText",
   2322        "link-color-visited": "VisitedText",
   2323        "dismiss-button-background":
   2324          "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(var(--newtab-background-color-secondary), var(--newtab-background-color-secondary))",
   2325        "dismiss-button-background-hover":
   2326          "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary)), color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary)))",
   2327        "dismiss-button-background-active":
   2328          "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary)), color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary)))",
   2329      },
   2330      dark: {
   2331        border:
   2332          "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)",
   2333        "button-background": "color-mix(in srgb, transparent 80%, #000)",
   2334        "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
   2335        "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
   2336      },
   2337      hcm: {
   2338        background: "-moz-dialog",
   2339        color: "-moz-dialogtext",
   2340        border: "-moz-dialogtext",
   2341        "accent-color": "LinkText",
   2342        "button-background": "ButtonFace",
   2343        "button-color": "ButtonText",
   2344        "button-border": "ButtonText",
   2345        "button-background-hover": "ButtonText",
   2346        "button-color-hover": "ButtonFace",
   2347        "button-border-hover": "ButtonText",
   2348        "button-background-active": "ButtonText",
   2349        "button-color-active": "ButtonFace",
   2350        "button-border-active": "ButtonText",
   2351        "dismiss-button-background": "-moz-dialog",
   2352        "dismiss-button-background-hover":
   2353          "color-mix(in srgb, currentColor 14%, SelectedItem)",
   2354        "dismiss-button-background-active":
   2355          "color-mix(in srgb, currentColor 21%, SelectedItem)",
   2356      },
   2357    },
   2358    // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css
   2359    pdfjs: {
   2360      all: {
   2361        background: "#FFF",
   2362        color: "rgb(12, 12, 13)",
   2363        border: "#CFCFD8",
   2364        "accent-color": "#0A84FF",
   2365        "button-background": "rgb(215, 215, 219)",
   2366        "button-color": "rgb(12, 12, 13)",
   2367        "button-border": "transparent",
   2368        "button-background-hover": "rgb(221, 222, 223)",
   2369        "button-color-hover": "rgb(12, 12, 13)",
   2370        "button-border-hover": "transparent",
   2371        "button-background-active": "rgb(221, 222, 223)",
   2372        "button-color-active": "rgb(12, 12, 13)",
   2373        "button-border-active": "transparent",
   2374        // use default primary button colors in _feature-callout-theme.scss
   2375        "link-color": "LinkText",
   2376        "link-color-hover": "LinkText",
   2377        "link-color-active": "ActiveText",
   2378        "link-color-visited": "VisitedText",
   2379        "dismiss-button-background": "#FFF",
   2380        "dismiss-button-background-hover":
   2381          "color-mix(in srgb, currentColor 14%, #FFF)",
   2382        "dismiss-button-background-active":
   2383          "color-mix(in srgb, currentColor 21%, #FFF)",
   2384      },
   2385      dark: {
   2386        background: "#1C1B22",
   2387        color: "#F9F9FA",
   2388        border: "#3A3944",
   2389        "button-background": "rgb(74, 74, 79)",
   2390        "button-color": "#F9F9FA",
   2391        "button-background-hover": "rgb(102, 102, 103)",
   2392        "button-color-hover": "#F9F9FA",
   2393        "button-background-active": "rgb(102, 102, 103)",
   2394        "button-color-active": "#F9F9FA",
   2395        "dismiss-button-background": "#1C1B22",
   2396        "dismiss-button-background-hover":
   2397          "color-mix(in srgb, currentColor 14%, #1C1B22)",
   2398        "dismiss-button-background-active":
   2399          "color-mix(in srgb, currentColor 21%, #1C1B22)",
   2400      },
   2401      hcm: {
   2402        background: "-moz-dialog",
   2403        color: "-moz-dialogtext",
   2404        border: "CanvasText",
   2405        "accent-color": "Highlight",
   2406        "button-background": "ButtonFace",
   2407        "button-color": "ButtonText",
   2408        "button-border": "ButtonText",
   2409        "button-background-hover": "Highlight",
   2410        "button-color-hover": "CanvasText",
   2411        "button-border-hover": "Highlight",
   2412        "button-background-active": "Highlight",
   2413        "button-color-active": "CanvasText",
   2414        "button-border-active": "Highlight",
   2415        "dismiss-button-background": "-moz-dialog",
   2416        "dismiss-button-background-hover":
   2417          "color-mix(in srgb, currentColor 14%, SelectedItem)",
   2418        "dismiss-button-background-active":
   2419          "color-mix(in srgb, currentColor 21%, SelectedItem)",
   2420      },
   2421    },
   2422    newtab: {
   2423      all: {
   2424        background:
   2425          "var(--newtab-background-color, #F9F9FB) linear-gradient(var(--newtab-background-color-secondary, #FFF), var(--newtab-background-color-secondary, #FFF))",
   2426        color: "var(--newtab-text-primary-color, WindowText)",
   2427        border:
   2428          "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)",
   2429        "accent-color": "#0061e0",
   2430        "button-background": "color-mix(in srgb, transparent 93%, #000)",
   2431        "button-color": "var(--newtab-text-primary-color, WindowText)",
   2432        "button-border": "transparent",
   2433        "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
   2434        "button-color-hover": "var(--newtab-text-primary-color, WindowText)",
   2435        "button-border-hover": "transparent",
   2436        "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
   2437        "button-color-active": "var(--newtab-text-primary-color, WindowText)",
   2438        "button-border-active": "transparent",
   2439        // use default primary button colors in _feature-callout-theme.scss
   2440        "link-color": "rgb(0, 97, 224)",
   2441        "link-color-hover": "rgb(0, 97, 224)",
   2442        "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)",
   2443        "link-color-visited": "rgb(0, 97, 224)",
   2444        "icon-success-color": "#2AC3A2",
   2445        "dismiss-button-background":
   2446          "var(--newtab-background-color, #F9F9FB) linear-gradient(var(--newtab-background-color-secondary, #FFF), var(--newtab-background-color-secondary, #FFF))",
   2447        "dismiss-button-background-hover":
   2448          "var(--newtab-background-color, #F9F9FB) linear-gradient(color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #FFF)), color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #FFF)))",
   2449        "dismiss-button-background-active":
   2450          "var(--newtab-background-color, #F9F9FB) linear-gradient(color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #FFF)), color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #FFF)))",
   2451      },
   2452      dark: {
   2453        "accent-color": "rgb(0, 221, 255)",
   2454        background:
   2455          "var(--newtab-background-color, #2B2A33) linear-gradient(var(--newtab-background-color-secondary, #42414D), var(--newtab-background-color-secondary, #42414D))",
   2456        border:
   2457          "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)",
   2458        "button-background": "color-mix(in srgb, transparent 80%, #000)",
   2459        "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
   2460        "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
   2461        "link-color": "rgb(0, 221, 255)",
   2462        "link-color-hover": "rgb(0,221,255)",
   2463        "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)",
   2464        "link-color-visited": "rgb(0, 221, 255)",
   2465        "icon-success-color": "#54FFBD",
   2466        "dismiss-button-background":
   2467          "var(--newtab-background-color, #2B2A33) linear-gradient(var(--newtab-background-color-secondary, #42414D), var(--newtab-background-color-secondary, #42414D))",
   2468        "dismiss-button-background-hover":
   2469          "var(--newtab-background-color, #2B2A33) linear-gradient(color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #42414D)), color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #42414D)))",
   2470        "dismiss-button-background-active":
   2471          "var(--newtab-background-color, #2B2A33) linear-gradient(color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #42414D), color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #42414D)))",
   2472      },
   2473      hcm: {
   2474        background: "-moz-dialog",
   2475        color: "-moz-dialogtext",
   2476        border: "-moz-dialogtext",
   2477        "accent-color": "SelectedItem",
   2478        "button-background": "ButtonFace",
   2479        "button-color": "ButtonText",
   2480        "button-border": "ButtonText",
   2481        "button-background-hover": "ButtonText",
   2482        "button-color-hover": "ButtonFace",
   2483        "button-border-hover": "ButtonText",
   2484        "button-background-active": "ButtonText",
   2485        "button-color-active": "ButtonFace",
   2486        "button-border-active": "ButtonText",
   2487        "link-color": "LinkText",
   2488        "link-color-hover": "LinkText",
   2489        "link-color-active": "ActiveText",
   2490        "link-color-visited": "VisitedText",
   2491        "dismiss-button-background": "-moz-dialog",
   2492        "dismiss-button-background-hover":
   2493          "color-mix(in srgb, currentColor 14%, SelectedItem)",
   2494        "dismiss-button-background-active":
   2495          "color-mix(in srgb, currentColor 21%, SelectedItem)",
   2496      },
   2497    },
   2498    // These colors are intended to inherit the user's theme properties from the
   2499    // main chrome window, for callouts to be anchored to chrome elements.
   2500    // Specific schemes aren't necessary since the theme and frontend
   2501    // stylesheets handle these variables' values.
   2502    chrome: {
   2503      all: {
   2504        // Use a gradient because it's possible (due to custom themes) that the
   2505        // arrowpanel-background will be semi-transparent, causing the arrow to
   2506        // show through the callout background. Put the Menu color behind the
   2507        // arrowpanel-background.
   2508        background:
   2509          "Menu linear-gradient(var(--arrowpanel-background), var(--arrowpanel-background))",
   2510        color: "var(--arrowpanel-color)",
   2511        border: "var(--arrowpanel-border-color)",
   2512        "accent-color": "var(--focus-outline-color)",
   2513        // Button Background
   2514        "button-background": "var(--button-background-color)",
   2515        "button-background-hover": "var(--button-background-color-hover)",
   2516        "button-background-active": "var(--button-background-color-active)",
   2517        "button-background-disabled": "var(--button-background-color-disabled)",
   2518        // Button Text
   2519        "button-color": "var(--button-text-color)",
   2520        "button-color-hover": "var(--button-text-color-hover)",
   2521        "button-color-active": "var(--button-text-color-active)",
   2522        // Button Border
   2523        "button-border": "var(--button-border-color)",
   2524        "button-border-color": "var(--button-border-color)",
   2525        "button-border-hover": "var(--button-border-color-hover)",
   2526        "button-border-active": "var(--button-border-color-active)",
   2527        "button-border-disabled": "var(--button-border-color-disabled)",
   2528        // Primary Button Background
   2529        "primary-button-background": "var(--button-background-color-primary)",
   2530        "primary-button-background-hover":
   2531          "var(--button-background-color-primary-hover)",
   2532        "primary-button-background-active":
   2533          "var(--button-background-color-primary-active)",
   2534        "primary-button-background-disabled":
   2535          "var(--button-background-color-primary-disabled)",
   2536        // Primary Button Color
   2537        "primary-button-color": "var(--button-text-color-primary)",
   2538        "primary-button-color-hover": "var(--button-text-color-primary)",
   2539        "primary-button-color-active": "var(--button-text-color-primary)",
   2540        "primary-button-color-disabled": "var(--button-text-color-primary)",
   2541        // Primary Button Border
   2542        "primary-button-border": "var(--button-border-color-primary)",
   2543        "primary-button-border-hover":
   2544          "var(--button-border-color-primary-hover)",
   2545        "primary-button-border-active":
   2546          "var(--button-border-color-primary-active)",
   2547        "primary-button-border-disabled":
   2548          "var(--button-border-color-primary-disabled)",
   2549        // Links
   2550        "link-color": "LinkText",
   2551        "link-color-hover": "LinkText",
   2552        "link-color-active": "ActiveText",
   2553        "link-color-visited": "VisitedText",
   2554        "icon-success-color": "var(--attention-dot-color)",
   2555        // Dismiss Button
   2556        "dismiss-button-background":
   2557          "Menu linear-gradient(var(--arrowpanel-background), var(--arrowpanel-background))",
   2558        "dismiss-button-background-hover":
   2559          "Menu linear-gradient(color-mix(in srgb, currentColor 14%, var(--arrowpanel-background)))",
   2560        "dismiss-button-background-active":
   2561          "Menu linear-gradient(color-mix(in srgb, currentColor 21%, var(--arrowpanel-background)))",
   2562      },
   2563      hcm: {
   2564        background: "var(--arrowpanel-background)",
   2565        "dismiss-button-background": "var(--arrowpanel-background)",
   2566        "dismiss-button-background-hover":
   2567          "color-mix(in srgb, currentColor 14%, SelectedItem)",
   2568        "dismiss-button-background-active":
   2569          "color-mix(in srgb, currentColor 21%, SelectedItem)",
   2570      },
   2571    },
   2572  };
   2573 }