summaryrefslogtreecommitdiffstats
path: root/browser/components/colorways
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/colorways')
-rw-r--r--browser/components/colorways/ColorwayClosetOpener.jsm35
-rw-r--r--browser/components/colorways/assets/independent-voices-activist.avifbin0 -> 30008 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-collection-banner.avifbin0 -> 25097 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-collection.avifbin0 -> 30622 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-dreamer.avifbin0 -> 30782 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-expressionist.avifbin0 -> 30784 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-innovator.avifbin0 -> 29714 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-playmaker.avifbin0 -> 28755 bytes
-rw-r--r--browser/components/colorways/assets/independent-voices-visionary.avifbin0 -> 29445 bytes
-rw-r--r--browser/components/colorways/colorwaycloset.css268
-rw-r--r--browser/components/colorways/colorwaycloset.html68
-rw-r--r--browser/components/colorways/colorwaycloset.js332
-rw-r--r--browser/components/colorways/jar.mn9
-rw-r--r--browser/components/colorways/moz.build14
-rw-r--r--browser/components/colorways/tests/browser/browser.ini8
-rw-r--r--browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js158
-rw-r--r--browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js379
-rw-r--r--browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js290
-rw-r--r--browser/components/colorways/tests/browser/head.js364
19 files changed, 1925 insertions, 0 deletions
diff --git a/browser/components/colorways/ColorwayClosetOpener.jsm b/browser/components/colorways/ColorwayClosetOpener.jsm
new file mode 100644
index 0000000000..f6dd3a0e2d
--- /dev/null
+++ b/browser/components/colorways/ColorwayClosetOpener.jsm
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ColorwayClosetOpener"];
+
+const { BrowserWindowTracker } = ChromeUtils.import(
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+
+let ColorwayClosetOpener = {
+ /**
+ * Opens the colorway closet modal.
+ * @param {String} source
+ * Indicates from where the modal was opened, and it is used for existing telemetry probes.
+ * Valid "source" types include: "aboutaddons", "firefoxview" and "unknown" (default).
+ * @param {Function} onClosed
+ * Function that is called after the modal is closed.
+ * @See Events.yaml for existing colorway closet probes
+ */
+ openModal: ({ source = "unknown", onClosed = null } = {}) => {
+ let { gBrowser } = BrowserWindowTracker.getTopWindow();
+ let dialogBox = gBrowser.getTabDialogBox(gBrowser.selectedBrowser);
+ return dialogBox.open(
+ "chrome://browser/content/colorways/colorwaycloset.html",
+ {
+ features: "resizable=no",
+ sizeTo: "available",
+ },
+ { source, onClosed }
+ );
+ },
+};
diff --git a/browser/components/colorways/assets/independent-voices-activist.avif b/browser/components/colorways/assets/independent-voices-activist.avif
new file mode 100644
index 0000000000..34a74742eb
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-activist.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-collection-banner.avif b/browser/components/colorways/assets/independent-voices-collection-banner.avif
new file mode 100644
index 0000000000..75e75e622e
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-collection-banner.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-collection.avif b/browser/components/colorways/assets/independent-voices-collection.avif
new file mode 100644
index 0000000000..3742723c59
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-collection.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-dreamer.avif b/browser/components/colorways/assets/independent-voices-dreamer.avif
new file mode 100644
index 0000000000..c2574395ce
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-dreamer.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-expressionist.avif b/browser/components/colorways/assets/independent-voices-expressionist.avif
new file mode 100644
index 0000000000..dde3c1a4ce
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-expressionist.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-innovator.avif b/browser/components/colorways/assets/independent-voices-innovator.avif
new file mode 100644
index 0000000000..a03d8f92ad
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-innovator.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-playmaker.avif b/browser/components/colorways/assets/independent-voices-playmaker.avif
new file mode 100644
index 0000000000..b67c7adede
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-playmaker.avif
Binary files differ
diff --git a/browser/components/colorways/assets/independent-voices-visionary.avif b/browser/components/colorways/assets/independent-voices-visionary.avif
new file mode 100644
index 0000000000..82d3a35933
--- /dev/null
+++ b/browser/components/colorways/assets/independent-voices-visionary.avif
Binary files differ
diff --git a/browser/components/colorways/colorwaycloset.css b/browser/components/colorways/colorwaycloset.css
new file mode 100644
index 0000000000..073ea17688
--- /dev/null
+++ b/browser/components/colorways/colorwaycloset.css
@@ -0,0 +1,268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+ height: 100%;
+
+ /* This will give us a larger base font size on macOS: */
+ font: menu;
+
+ --body-columns: 1fr;
+ --body-rows: auto auto 1fr auto;
+
+ --figure-width: min(37.5vw, 300px);
+
+ --card-border-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%);
+
+ --colorway-selector-align: center;
+ --colorway-name-font-size: 1.5em;
+
+ --homepage-reset-column: 1;
+ --homepage-reset-align: start;
+}
+
+@media (max-width: 560px) {
+ :root {
+ --font-scale: 0.9em;
+ }
+}
+
+@media (min-width: 640px) {
+ :root {
+ --body-columns: 1em auto 1fr;
+ --body-rows: auto 1fr auto;
+
+ --header-column: 2/4;
+
+ --figure-column: 2;
+ --figure-row: 2;
+
+ --customization-panel-column: 3;
+ --customization-panel-padding-inline-start: 2.5em;
+
+ --colorway-selector-align: start;
+ --colorway-name-font-size: 2em;
+
+ --homepage-reset-column: 1/4;
+ --homepage-reset-align: end;
+ }
+}
+
+body {
+ height: 100%;
+ display: grid;
+ grid-template-columns: var(--body-columns);
+ grid-template-rows: var(--body-rows);
+ padding: 0 2em 1em;
+ box-sizing: border-box;
+ font-size: var(--font-scale);
+}
+
+fieldset {
+ border: unset;
+ margin: 0;
+ padding: 0;
+}
+
+/* Header */
+
+body > header {
+ grid-row: 1;
+ margin-top: 1em;
+ grid-column: var(--header-column);
+}
+
+#collection-title {
+ display: inline-block;
+ margin-inline: 0 .7em;
+ margin-block: 0 .2em;
+ padding: 0;
+ font-size: 1.5em;
+ font-weight: bold;
+}
+
+#collection-expiry-date {
+ display: inline-block;
+ background: var(--card-border-zap-gradient);
+ background-origin: border-box;
+ border: 1px solid transparent;
+ border-radius: 1.5em;
+}
+
+#collection-expiry-date > span {
+ display: inline-block;
+ color: var(--in-content-page-color);
+ background: var(--in-content-page-background);
+ border-radius: 1.5em;
+ padding: .3em 1em;
+}
+
+/* Illustration */
+
+figure {
+ grid-column: var(--figure-column);
+ grid-row: var(--figure-row);
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ margin: 1em 0 0;
+ min-width: var(--figure-width);
+ min-height: var(--figure-width);
+}
+
+figure > img {
+ max-width: var(--figure-width);
+ max-height: var(--figure-width);
+ object-fit: scale-down;
+}
+
+/* Selector and colorway details */
+
+#colorway-customization-panel {
+ align-self: stretch;
+ padding-inline-start: var(--customization-panel-padding-inline-start);
+ grid-column: var(--customization-panel-column);
+ display: flex;
+ flex-direction: column;
+}
+
+#colorway-customization-panel > * {
+ flex: 1;
+}
+
+#colorway-selector,
+#modal-buttons {
+ flex: 3;
+ display: flex;
+ align-items: center;
+}
+
+#colorway-selector {
+ justify-content: var(--colorway-selector-align);
+}
+
+#colorway-selector > input[type="radio"],
+#colorway-selector > input[type="radio"]:checked {
+ box-sizing: content-box;
+ padding: 2px;
+ border: 2px solid transparent;
+ height: 24px;
+ width: 24px;
+ --colorway-icon: none;
+ appearance: none;
+ background-color: unset;
+ background-image: var(--colorway-icon);
+ background-origin: content-box;
+ background-position: center;
+ background-repeat: no-repeat;
+ /* The icon may not be a perfect circle, so we render it bigger and clipped using background-clip and border-radius: */
+ background-clip: content-box;
+ background-size: 105%;
+ border-radius: 50%;
+}
+
+#colorway-selector > input[type="radio"]:enabled:checked,
+#colorway-selector > input[type="radio"]:enabled:checked:hover {
+ border-color: var(--in-content-accent-color);
+}
+
+/* override common-shared.css */
+#colorway-selector > input[type="radio"]:is(:enabled:hover, :enabled:hover:active, :checked, :enabled:checked:hover, :enabled:checked:hover:active) {
+ background-color: unset;
+}
+
+#colorway-name {
+ font-size: var(--colorway-name-font-size);
+ margin: 0;
+}
+
+#colorway-description {
+ line-height: 1.5;
+ flex: 2;
+}
+
+/* Intensity Picker */
+
+#colorway-intensities > legend {
+ padding-inline-start: 0;
+ margin-bottom: .5em;
+ color: var(--in-content-deemphasized-text);
+}
+
+#colorway-intensity-radios {
+ display: flex;
+ justify-content: space-between;
+ gap: .5em;
+}
+
+#colorway-intensity-radios > label {
+ background-color: var(--in-content-box-background-color);
+ border-radius: 4px;
+ border: 1px solid var(--in-content-border-color);
+ color: var(--in-content-text-color);
+
+ flex: 1;
+ overflow: clip;
+ padding: .5em;
+
+ display: flex;
+ align-items: center;
+}
+
+.colorway-intensity-radio {
+ margin-block: 0 !important;
+}
+
+#set-colorway {
+ margin-inline-start: 0;
+}
+
+/* Homepage reset footer */
+
+#homepage-reset-container:not([hidden]) {
+ display: flex;
+ grid-column: var(--homepage-reset-column);
+}
+
+#homepage-reset-prompt,
+#homepage-reset-success {
+ width: 100%;
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: var(--homepage-reset-align);
+}
+
+#homepage-reset-prompt > span,
+#homepage-reset-success > span {
+ padding-inline-end: 1em;
+}
+
+#homepage-reset-container:not(.success) > #homepage-reset-success,
+#homepage-reset-container.success > #homepage-reset-prompt {
+ display: none;
+}
+
+#homepage-reset-success > span {
+ position: relative;
+ padding-inline-start: calc(22px + .5em);
+}
+
+#homepage-reset-success > span::before {
+ content: "";
+
+ background: var(--green-70) url('chrome://global/skin/icons/check.svg') center center no-repeat;
+ -moz-context-properties: fill;
+ fill: white;
+ border-radius: 100%;
+
+ width: 22px;
+ height: 22px;
+
+ position: absolute;
+ inset-inline-start: 0;
+ inset-block-start: calc((1em - 22px) / 2);
+}
diff --git a/browser/components/colorways/colorwaycloset.html b/browser/components/colorways/colorwaycloset.html
new file mode 100644
index 0000000000..2956b64c2b
--- /dev/null
+++ b/browser/components/colorways/colorwaycloset.html
@@ -0,0 +1,68 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html style="max-width: 67em; max-height: 40em;">
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'">
+ <meta name="color-scheme" content="light dark">
+ <title data-l10n-id="colorways-modal-title"></title>
+ <link rel="stylesheet" type="text/css"
+ href="chrome://global/skin/in-content/common.css">
+ <link rel="stylesheet" type="text/css"
+ href="chrome://browser/content/colorways/colorwaycloset.css">
+ <link rel="localization" href="branding/brand.ftl">
+ <link rel="localization" href="browser/branding/brandings.ftl">
+ <link rel="localization" href="browser/colorwaycloset.ftl">
+ <link rel="localization" href="browser/colorways.ftl">
+ <script src="chrome://browser/content/colorways/colorwaycloset.js" defer></script>
+</head>
+<body>
+ <header>
+ <h1 id="collection-title"></h1>
+ <span id="collection-expiry-date"><span></span></span>
+ </header>
+ <figure role="presentation">
+ <img id="colorway-figure" role="presentation">
+ </figure>
+ <main id="colorway-customization-panel">
+ <fieldset id="colorway-selector"></fieldset>
+ <h2 id="colorway-name"></h2>
+ <p id="colorway-description"></p>
+ <fieldset id="colorway-intensities">
+ <legend data-l10n-id="colorway-intensity-selector-label"></legend>
+ <div id="colorway-intensity-radios">
+ <label>
+ <input type="radio" name="intensity" class="colorway-intensity-radio" data-intensity="soft">
+ <span data-l10n-id="colorway-intensity-soft"></span>
+ </label>
+ <label>
+ <input type="radio" name="intensity" class="colorway-intensity-radio" data-intensity="balanced">
+ <span data-l10n-id="colorway-intensity-balanced"></span>
+ </label>
+ <label>
+ <input type="radio" name="intensity" class="colorway-intensity-radio" data-intensity="bold">
+ <span data-l10n-id="colorway-intensity-bold"></span>
+ </label>
+ </div>
+ </fieldset>
+ <fieldset id="modal-buttons">
+ <button id="set-colorway" data-l10n-id="colorway-closet-set-colorway-button" class="primary"></button>
+ <button id="cancel" data-l10n-id="colorway-closet-cancel-button"></button>
+ </fieldset>
+ </main>
+ <footer id="homepage-reset-container" hidden>
+ <div id="homepage-reset-prompt">
+ <span data-l10n-id="colorway-homepage-reset-prompt"></span>
+ <button data-l10n-id="colorway-homepage-reset-apply-button"></button>
+ </div>
+ <div id="homepage-reset-success">
+ <span data-l10n-id="colorway-homepage-reset-success-message"></span>
+ <button data-l10n-id="colorway-homepage-reset-undo-button"></button>
+ </div>
+ </footer>
+</body>
+</html>
diff --git a/browser/components/colorways/colorwaycloset.js b/browser/components/colorways/colorwaycloset.js
new file mode 100644
index 0000000000..63d9601b8d
--- /dev/null
+++ b/browser/components/colorways/colorwaycloset.js
@@ -0,0 +1,332 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+
+const INTENSITY_SOFT = "soft";
+const INTENSITY_BALANCED = "balanced";
+const INTENSITY_BOLD = "bold";
+const ID_SUFFIX_COLORWAY = "-colorway@mozilla.org";
+const ID_SUFFIX_PRIMARY_INTENSITY = `-${INTENSITY_BALANCED}${ID_SUFFIX_COLORWAY}`;
+const ID_SUFFIX_DARK_COLORWAY = `-${INTENSITY_BOLD}${ID_SUFFIX_COLORWAY}`;
+const ID_SUFFIXES_FOR_SECONDARY_INTENSITIES = new RegExp(
+ `-(${INTENSITY_SOFT}|${INTENSITY_BOLD})-colorway@mozilla\\.org$`
+);
+const MATCH_INTENSITY_FROM_ID = new RegExp(
+ `-(${INTENSITY_SOFT}|${INTENSITY_BALANCED}|${INTENSITY_BOLD})-colorway@mozilla\\.org$`
+);
+
+/**
+ * Helper for colorway closet related telemetry probes.
+ */
+const ColorwaysTelemetry = {
+ init() {
+ Services.telemetry.setEventRecordingEnabled("colorways_modal", true);
+ },
+
+ recordEvent(telemetryEventData) {
+ if (!telemetryEventData || !Object.entries(telemetryEventData).length) {
+ console.error("Unable to record event due to missing telemetry data");
+ return;
+ }
+
+ if (telemetryEventData.source && ColorwayCloset.previousTheme?.id) {
+ telemetryEventData.method = BuiltInThemes.isColorwayFromCurrentCollection(
+ ColorwayCloset.previousTheme.id
+ )
+ ? "change_colorway"
+ : "try_colorways";
+ telemetryEventData.object = telemetryEventData.source;
+ }
+ Services.telemetry.recordEvent(
+ "colorways_modal",
+ telemetryEventData.method,
+ telemetryEventData.object,
+ telemetryEventData.value || null,
+ telemetryEventData.extraKeys || null
+ );
+ },
+};
+
+const ColorwayCloset = {
+ // This is essentially an instant-apply dialog, but we make an effort to only
+ // keep the theme change if the user hits the "Set colorway" button, and
+ // otherwise revert to the previous theme upon closing the modal. However,
+ // this doesn't cover the application quitting while the modal is open, in
+ // which case the theme change will be kept.
+ revertToPreviousTheme: true,
+
+ el: {
+ colorwayRadios: document.getElementById("colorway-selector"),
+ intensityContainer: document.getElementById("colorway-intensities"),
+ colorwayFigure: document.getElementById("colorway-figure"),
+ colorwayName: document.getElementById("colorway-name"),
+ collectionTitle: document.getElementById("collection-title"),
+ colorwayDescription: document.getElementById("colorway-description"),
+ expiryDateSpan: document.querySelector("#collection-expiry-date > span"),
+ setColorwayButton: document.getElementById("set-colorway"),
+ cancelButton: document.getElementById("cancel"),
+ homepageResetContainer: document.getElementById("homepage-reset-container"),
+ },
+
+ init() {
+ window.addEventListener("unload", this);
+
+ ColorwaysTelemetry.init();
+
+ this._displayCollectionData();
+
+ AddonManager.addAddonListener(this);
+ this._initColorwayRadios().then(() => {
+ this.el.colorwayRadios.addEventListener("change", this);
+ this.el.intensityContainer.addEventListener("change", this);
+
+ let args = window?.arguments?.[0];
+ // Record telemetry probes that are called upon opening the modal.
+ ColorwaysTelemetry.recordEvent(args);
+
+ this.el.setColorwayButton.onclick = () => {
+ this.revertToPreviousTheme = false;
+ window.close();
+ };
+ });
+
+ this.el.cancelButton.onclick = () => {
+ window.close();
+ };
+
+ this._displayHomepageResetOption();
+ },
+
+ async _initColorwayRadios() {
+ BuiltInThemes.ensureBuiltInThemes();
+ let themes = await AddonManager.getAddonsByTypes(["theme"]);
+ this.previousTheme = themes.find(theme => theme.isActive);
+ this.colorways = themes.filter(theme =>
+ BuiltInThemes.isColorwayFromCurrentCollection(theme.id)
+ );
+
+ // The radio buttons represent colorway "groups". A group is a colorway
+ // from the current collection to represent related colorways with another
+ // intensity. If the current collection doesn't have intensities, each
+ // colorway is their own group.
+ this.colorwayGroups = this.colorways.filter(
+ colorway => !ID_SUFFIXES_FOR_SECONDARY_INTENSITIES.test(colorway.id)
+ );
+
+ for (const addon of this.colorwayGroups) {
+ let input = document.createElement("input");
+ input.type = "radio";
+ input.name = "colorway";
+ input.value = addon.id;
+ input.setAttribute("title", this._getColorwayGroupName(addon));
+ if (addon.iconURL) {
+ input.style.setProperty("--colorway-icon", `url(${addon.iconURL})`);
+ }
+ this.el.colorwayRadios.appendChild(input);
+ }
+
+ // If the current active theme is part of our collection, make the UI reflect
+ // that. Otherwise go ahead and enable the first colorway in our list.
+ this.selectedColorway = this.colorways.find(colorway => colorway.isActive);
+ if (this.selectedColorway) {
+ this.refresh();
+ } else {
+ let colorwayToEnable = this.colorwayGroups[0];
+ // If the user has been using a theme with a dark color scheme, make an
+ // effort to default to a colorway with a dark color scheme as well.
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
+ let firstDarkColorway = this.colorways.find(colorway =>
+ colorway.id.endsWith(ID_SUFFIX_DARK_COLORWAY)
+ );
+ colorwayToEnable = firstDarkColorway || colorwayToEnable;
+ }
+ colorwayToEnable.enable();
+ }
+ },
+
+ _displayHomepageResetOption() {
+ const { HomePage } = ChromeUtils.import("resource:///modules/HomePage.jsm");
+ this.el.homepageResetContainer.hidden = HomePage.isDefault;
+ if (!HomePage.isDefault) {
+ let homeState;
+ this.el.homepageResetContainer
+ .querySelector("#homepage-reset-prompt > button")
+ .addEventListener("click", () => {
+ ColorwaysTelemetry.recordEvent({
+ method: "homepage_reset",
+ object: "modal",
+ });
+ homeState = HomePage.get();
+ HomePage.reset();
+ this.el.homepageResetContainer.classList.add("success");
+ });
+ this.el.homepageResetContainer
+ .querySelector("#homepage-reset-success > button")
+ .addEventListener("click", () => {
+ ColorwaysTelemetry.recordEvent({
+ method: "homepage_reset_undo",
+ object: "modal",
+ });
+ HomePage.set(homeState);
+ this.el.homepageResetContainer.classList.remove("success");
+ });
+ }
+ },
+
+ _displayCollectionData() {
+ const collection = BuiltInThemes.findActiveColorwayCollection();
+ if (!collection) {
+ // Whoops. There should be no entry point to this UI without an active
+ // collection.
+ window.close();
+ }
+ document.l10n.setAttributes(
+ this.el.collectionTitle,
+ collection.l10nId.title
+ );
+ document.l10n.setAttributes(
+ this.el.expiryDateSpan,
+ "colorway-collection-expiry-label",
+ {
+ expiryDate: collection.expiry.getTime(),
+ }
+ );
+ },
+
+ _getFigureUrl() {
+ return BuiltInThemes.builtInThemeMap.get(this.selectedColorway.id)
+ .figureUrl;
+ },
+
+ _displayColorwayData() {
+ this.el.colorwayName.innerText = this._getColorwayGroupName(
+ this.selectedColorway
+ );
+ this.el.colorwayDescription.innerText = this.selectedColorway.description;
+ this.el.colorwayFigure.src = this._getFigureUrl();
+
+ this.el.intensityContainer.hidden = !this.hasIntensities;
+ if (this.hasIntensities) {
+ let selectedIntensity = this.selectedColorway.id.match(
+ MATCH_INTENSITY_FROM_ID
+ )[1];
+ for (let radio of document.querySelectorAll(
+ ".colorway-intensity-radio"
+ )) {
+ let intensity = radio.getAttribute("data-intensity");
+ radio.value = this._changeIntensity(
+ this.selectedColorway.id,
+ intensity
+ );
+ if (intensity == selectedIntensity) {
+ radio.checked = true;
+ }
+ }
+ }
+ },
+
+ _getColorwayGroupId(colorwayId) {
+ let groupId = colorwayId.replace(
+ ID_SUFFIXES_FOR_SECONDARY_INTENSITIES,
+ ID_SUFFIX_PRIMARY_INTENSITY
+ );
+ return this.colorwayGroups.map(addon => addon.id).includes(groupId)
+ ? groupId
+ : null;
+ },
+
+ _changeIntensity(colorwayId, intensity) {
+ return colorwayId.replace(
+ MATCH_INTENSITY_FROM_ID,
+ `-${intensity}${ID_SUFFIX_COLORWAY}`
+ );
+ },
+
+ _getColorwayGroupName(addon) {
+ return BuiltInThemes.getLocalizedColorwayGroupName(addon.id) || addon.name;
+ },
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "change":
+ let newId = e.target.value;
+ // Persist the selected intensity when toggling between colorway radios.
+ if (e.currentTarget == this.el.colorwayRadios && this.hasIntensities) {
+ let selectedIntensity = document
+ .querySelector(".colorway-intensity-radio:checked")
+ .getAttribute("data-intensity");
+ newId = this._changeIntensity(newId, selectedIntensity);
+ }
+ this.colorways.find(colorway => colorway.id == newId).enable();
+ break;
+ case "unload":
+ AddonManager.removeAddonListener(this);
+ if (this.revertToPreviousTheme) {
+ this.previousTheme.enable();
+ ColorwaysTelemetry.recordEvent({
+ method: "cancel",
+ object: "modal",
+ });
+ } else {
+ ColorwaysTelemetry.recordEvent({
+ method: "set_colorway",
+ object: "modal",
+ value: null,
+ extraKeys: { colorway_id: this.selectedColorway.id },
+ });
+ }
+
+ // `onClosed` is an additional callback passed upon opening the modal.
+ // Here, `onClosed` is passed by about:addons and is called after the
+ // the modal closes. This is to defer re-rendering the about:addons page
+ // until the user has closed the dialog by setting a new colorways theme
+ // or pressing cancel to revert to the previous theme.
+ const { onClosed } = window?.arguments?.[0] || {};
+ onClosed?.({ colorwayChanged: !this.revertToPreviousTheme });
+ break;
+ }
+ },
+
+ onEnabled(addon) {
+ if (addon.type == "theme") {
+ if (!this.colorways.find(colorway => colorway.id == addon.id)) {
+ // The selected theme changed to a non-colorway from outside the modal.
+ // This UI can't represent that state, so bail out.
+ this.revertToPreviousTheme = false;
+ window.close();
+ return;
+ }
+ this.selectedColorway = addon;
+ this.refresh();
+ }
+ },
+
+ refresh() {
+ this.groupIdForSelectedColorway = this._getColorwayGroupId(
+ this.selectedColorway.id
+ );
+ this.hasIntensities = this.groupIdForSelectedColorway.endsWith(
+ ID_SUFFIX_PRIMARY_INTENSITY
+ );
+ for (let input of this.el.colorwayRadios.children) {
+ if (input.value == this.groupIdForSelectedColorway) {
+ input.checked = true;
+ this._displayColorwayData();
+ break;
+ }
+ }
+ this.el.setColorwayButton.disabled =
+ this.previousTheme.id == this.selectedColorway.id;
+ },
+};
+
+ColorwayCloset.init();
diff --git a/browser/components/colorways/jar.mn b/browser/components/colorways/jar.mn
new file mode 100644
index 0000000000..cac5627f9d
--- /dev/null
+++ b/browser/components/colorways/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+browser.jar:
+ content/browser/colorways/colorwaycloset.html
+ content/browser/colorways/colorwaycloset.css
+ content/browser/colorways/colorwaycloset.js
+ content/browser/colorways/assets/ (assets/*.avif)
diff --git a/browser/components/colorways/moz.build b/browser/components/colorways/moz.build
new file mode 100644
index 0000000000..17eb613713
--- /dev/null
+++ b/browser/components/colorways/moz.build
@@ -0,0 +1,14 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Theme")
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "ColorwayClosetOpener.jsm",
+]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
diff --git a/browser/components/colorways/tests/browser/browser.ini b/browser/components/colorways/tests/browser/browser.ini
new file mode 100644
index 0000000000..02588ca661
--- /dev/null
+++ b/browser/components/colorways/tests/browser/browser.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+run-if = nightly_build
+support-files =
+ head.js
+
+[browser_colorwayCloset_modalButtons.js]
+[browser_colorwayCloset_modalUI.js]
+[browser_colorwayCloset_telemetry.js]
diff --git a/browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js
new file mode 100644
index 0000000000..972e7d3eed
--- /dev/null
+++ b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalButtons.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the selected colorway is still enabled after pressing the Set Colorway button
+ * in the modal.
+ */
+add_task(async function colorwaycloset_modal_set_colorway() {
+ await testInColorwayClosetModal(async (document, contentWindow) => {
+ // Select colorway on modal
+ info("Selecting colorway radio button");
+ const {
+ colorwayIntensities,
+ setColorwayButton,
+ } = getColorwayClosetTestElements(document);
+
+ // Select new intensity
+ // Wait for intensity button to be initialized
+ await BrowserTestUtils.waitForMutationCondition(
+ colorwayIntensities,
+ { subtree: true, attributeFilter: ["value"] },
+ () =>
+ colorwayIntensities.querySelector(
+ `input[value="${SOFT_COLORWAY_THEME_ID}"]`
+ ),
+ "Waiting for intensity button to be available"
+ );
+ let intensitiesChangedPromise = BrowserTestUtils.waitForEvent(
+ colorwayIntensities,
+ "change",
+ "Waiting for intensities change event"
+ );
+ let themeChangedPromise = waitForAddonEnabled(SOFT_COLORWAY_THEME_ID);
+
+ info("Selecting new intensity");
+ colorwayIntensities
+ .querySelector(`input[value="${SOFT_COLORWAY_THEME_ID}"]`)
+ .click();
+ await intensitiesChangedPromise;
+ await themeChangedPromise;
+
+ // Set colorway
+ await BrowserTestUtils.waitForMutationCondition(
+ setColorwayButton,
+ { childList: true, attributeFilter: ["disabled"] },
+ () => !setColorwayButton.disabled,
+ "Waiting for set-colorway button to be available for selection"
+ );
+ let modalClosedPromise = BrowserTestUtils.waitForEvent(
+ contentWindow,
+ "unload",
+ "Waiting for modal to close"
+ );
+
+ info("Selecting set colorway button");
+ setColorwayButton.click();
+ info("Closing modal");
+ await modalClosedPromise;
+
+ // Verify theme selection is saved
+ const activeTheme = Services.prefs.getStringPref(
+ "extensions.activeThemeID"
+ );
+ is(
+ activeTheme,
+ SOFT_COLORWAY_THEME_ID,
+ "Current theme is still selected colorway"
+ );
+ });
+});
+
+/**
+ * Tests that the selected colorway is not enabled after pressing the Cancel Button.
+ * Theme should be reverted to previous option.
+ */
+add_task(async function colorwaycloset_modal_cancel() {
+ const previousTheme = Services.prefs.getStringPref(
+ "extensions.activeThemeID"
+ );
+ info(`Previous theme is ${previousTheme}`);
+
+ await testInColorwayClosetModal(
+ async (document, contentWindow) => {
+ // Wait for colorway to load before checking modal UI
+ await waitForAddonEnabled(NO_INTENSITY_COLORWAY_THEME_ID);
+
+ // Cancel colorway selection
+ const { cancelButton } = getColorwayClosetTestElements(document);
+ let themeRevertedPromise = waitForAddonEnabled(previousTheme);
+ let modalClosedPromise = BrowserTestUtils.waitForEvent(
+ contentWindow,
+ "unload",
+ "Waiting for modal to close"
+ );
+ info("Selecting cancel button");
+ cancelButton.click();
+
+ info("Verifying that previous theme is restored");
+ await themeRevertedPromise;
+ info("Closing modal");
+ await modalClosedPromise;
+ },
+ [NO_INTENSITY_COLORWAY_THEME_ID]
+ );
+});
+
+/**
+ * Tests that the Firefox homepage apply and undo options are visible
+ * on the modal if a non-default setting for homepage is enabled.
+ */
+add_task(async function colorwaycloset_custom_home_page() {
+ await HomePage.set("https://www.example.com");
+ await testInColorwayClosetModal(document => {
+ const { homepageResetApplyButton } = getColorwayClosetTestElements(
+ document
+ );
+ let v = getColorwayClosetElementVisibility(document);
+ ok(
+ v.homepageResetContainer.isVisible,
+ "Homepage reset prompt should be shown"
+ );
+ ok(
+ v.homepageResetSuccessMessage.isHidden,
+ "Success message should not be shown"
+ );
+ ok(v.homepageResetUndoButton.isHidden, "Undo button should not be shown");
+ ok(v.homepageResetMessage.isVisible, "Reset message should be shown");
+ ok(v.homepageResetApplyButton.isVisible, "Apply button should be shown");
+
+ homepageResetApplyButton.click();
+ v = getColorwayClosetElementVisibility(document);
+
+ ok(
+ v.homepageResetSuccessMessage.isVisible,
+ "Success message should be shown"
+ );
+ ok(v.homepageResetUndoButton.isVisible, "Undo button should be shown");
+ ok(v.homepageResetMessage.isHidden, "Reset message should not be shown");
+ ok(v.homepageResetApplyButton.isHidden, "Apply button should not be shown");
+ });
+});
+
+/**
+ * Tests that the Firefox homepage apply and undo options are not visible
+ * on the modal if the default setting for homepage is enabled.
+ */
+add_task(async function colorwaycloset_default_home_page() {
+ await HomePage.set(HomePage.getOriginalDefault());
+ await testInColorwayClosetModal(document => {
+ const { homepageResetContainer } = getColorwayClosetTestElements(document);
+ ok(
+ BrowserTestUtils.is_hidden(homepageResetContainer),
+ "Homepage reset prompt should be hidden"
+ );
+ });
+});
diff --git a/browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js
new file mode 100644
index 0000000000..82b2fe9274
--- /dev/null
+++ b/browser/components/colorways/tests/browser/browser_colorwayCloset_modalUI.js
@@ -0,0 +1,379 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that all colorway details such as the collection title, collection expiry date,
+ * and colorway figure are displayed on the modal.
+ */
+add_task(async function colorwaycloset_show_colorway() {
+ await testInColorwayClosetModal(async document => {
+ is(
+ document.documentElement.style.width,
+ "",
+ "In order for the modal layout to be responsive, the modal document " +
+ "should not have a width set after the dialog frame has been set up"
+ );
+ const el = getColorwayClosetTestElements(document);
+ const expiryL10nAttributes = document.l10n.getAttributes(el.expiryDateSpan);
+ is(
+ document.l10n.getAttributes(el.collectionTitle).id,
+ TEST_COLORWAY_COLLECTION.l10nId.title,
+ "Correct collection title should be shown"
+ );
+ is(
+ expiryL10nAttributes.args.expiryDate,
+ TEST_COLORWAY_COLLECTION.expiry.getTime(),
+ "Correct expiry date should be shown"
+ );
+ is(
+ expiryL10nAttributes.id,
+ EXPIRY_DATE_L10N_ID,
+ "Correct expiry date format should be shown"
+ );
+
+ info("Verifying figure src");
+ await BrowserTestUtils.waitForMutationCondition(
+ el.colorwayFigure,
+ { childList: true, attributeFilter: ["src"] },
+ () => el.colorwayFigure.src === MOCK_THEME_FIGURE_URL,
+ "Waiting for figure image to have expected URL"
+ );
+ });
+});
+
+/**
+ * Tests that modal details and theme are updated when a new colorway radio button is selected.
+ */
+add_task(async function colorwaycloset_modal_select_colorway() {
+ await testInColorwayClosetModal(
+ async document => {
+ const {
+ colorwaySelector,
+ colorwayName,
+ colorwayDescription,
+ colorwayIntensities,
+ } = getColorwayClosetTestElements(document);
+
+ is(
+ colorwaySelector.children.length,
+ 2,
+ "There should be two colorway radio buttons"
+ );
+
+ // Select colorway on modal
+ const colorwayGroupButton1 = colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ );
+ let selectorChangedPromise = BrowserTestUtils.waitForEvent(
+ colorwaySelector,
+ "change",
+ "Waiting for radio button change event"
+ );
+ let themeChangedPromise = waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID);
+
+ info("Selecting colorway radio button");
+ colorwayGroupButton1.click();
+ info("Waiting for radio button change event to resolve");
+ await selectorChangedPromise;
+ info("Waiting for theme to change");
+ await themeChangedPromise;
+
+ // Verify values of first colorway
+ is(colorwayName.textContent, MOCK_THEME_NAME, "Theme name is correct");
+ is(
+ colorwayDescription.textContent,
+ MOCK_THEME_DESCRIPTION,
+ "Theme description is correct"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${SOFT_COLORWAY_THEME_ID}"]`
+ ),
+ "Soft intensity should be correct"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ ),
+ "Balanced intensity should be correct"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BOLD_COLORWAY_THEME_ID}"]`
+ ),
+ "Bold intensity should be correct"
+ );
+
+ // Select a new colorway
+ const colorwayGroupButton2 = colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID_2}"]`
+ );
+ selectorChangedPromise = BrowserTestUtils.waitForEvent(
+ colorwaySelector,
+ "change",
+ "Waiting for radio button change event"
+ );
+ themeChangedPromise = waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID_2);
+
+ info("Selecting another colorway radio button");
+ colorwayGroupButton2.click();
+ info("Waiting for radio button change event to resolve");
+ await selectorChangedPromise;
+ info("Waiting for theme to change");
+ await themeChangedPromise;
+
+ // Verify values of new colorway
+ is(colorwayName.textContent, MOCK_THEME_NAME_2, "Theme name is updated");
+ is(
+ colorwayDescription.textContent,
+ MOCK_THEME_DESCRIPTION_2,
+ "Theme description is updated"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${SOFT_COLORWAY_THEME_ID_2}"]`
+ ),
+ "Soft intensity should be updated"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID_2}"]`
+ ),
+ "Balanced intensity should be updated"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BOLD_COLORWAY_THEME_ID_2}"]`
+ ),
+ "Bold intensity should be updated"
+ );
+ },
+ [
+ SOFT_COLORWAY_THEME_ID,
+ BALANCED_COLORWAY_THEME_ID,
+ BOLD_COLORWAY_THEME_ID,
+ SOFT_COLORWAY_THEME_ID_2,
+ BALANCED_COLORWAY_THEME_ID_2,
+ BOLD_COLORWAY_THEME_ID_2,
+ ]
+ );
+});
+
+/**
+ * Tests that modal details and theme are updated when a new colorway radio button is selected,
+ * but when the colorway has no intensity options.
+ */
+add_task(async function colorwaycloset_modal_select_colorway_no_intensity() {
+ await testInColorwayClosetModal(
+ async document => {
+ // Select colorway on modal
+ const {
+ colorwaySelector,
+ colorwayName,
+ colorwayDescription,
+ colorwayIntensities,
+ } = getColorwayClosetTestElements(document);
+
+ is(
+ colorwaySelector.children.length,
+ 2,
+ "There should be two colorway radio buttons"
+ );
+
+ const colorwayGroupButton1 = colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ );
+ let selectorChangedPromise = BrowserTestUtils.waitForEvent(
+ colorwaySelector,
+ "change",
+ "Waiting for radio button change event"
+ );
+ let themeChangedPromise = waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID);
+
+ info("Selecting colorway radio button");
+ colorwayGroupButton1.click();
+ info("Waiting for radio button change event to resolve");
+ await selectorChangedPromise;
+ info("Waiting for theme to change");
+ await themeChangedPromise;
+
+ // Verify values of first colorway
+ is(colorwayName.textContent, MOCK_THEME_NAME, "Theme name is correct");
+ is(
+ colorwayDescription.textContent,
+ MOCK_THEME_DESCRIPTION,
+ "Theme description is correct"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${SOFT_COLORWAY_THEME_ID}"]`
+ ),
+ "Soft intensity should be correct"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ ),
+ "Balanced intensity should be correct"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BOLD_COLORWAY_THEME_ID}"]`
+ ),
+ "Bold intensity should be correct"
+ );
+
+ // Select a new colorway
+ const colorwayGroupButton2 = colorwaySelector.querySelector(
+ `input[value="${NO_INTENSITY_COLORWAY_THEME_ID}"]`
+ );
+ selectorChangedPromise = BrowserTestUtils.waitForEvent(
+ colorwaySelector,
+ "change",
+ "Waiting for radio button change event"
+ );
+ themeChangedPromise = waitForAddonEnabled(NO_INTENSITY_COLORWAY_THEME_ID);
+
+ info("Selecting another colorway radio button but with no intensity");
+ colorwayGroupButton2.click();
+ info("Waiting for radio button change event to resolve");
+ await selectorChangedPromise;
+ info("Waiting for theme to change");
+ await themeChangedPromise;
+
+ // Verify there are no intensities
+ info("Verifying intensity button is not visible");
+ let v = getColorwayClosetElementVisibility(document);
+ ok(
+ !v.colorwayIntensities.isVisible,
+ "Colorway intensities should not be shown"
+ );
+ },
+ [
+ SOFT_COLORWAY_THEME_ID,
+ BALANCED_COLORWAY_THEME_ID,
+ BOLD_COLORWAY_THEME_ID,
+ NO_INTENSITY_COLORWAY_THEME_ID,
+ ]
+ );
+});
+
+/**
+ * Tests that an active colorway in the current collection is selected and displayed when
+ * opening the modal.
+ */
+add_task(async function colorwaycloset_modal_show_active_colorway() {
+ await testInColorwayClosetModal(
+ async document => {
+ const {
+ colorwaySelector,
+ colorwayIntensities,
+ } = getColorwayClosetTestElements(document);
+ info(
+ "Verify that the correct colorway family button is checked by default"
+ );
+ ok(
+ colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ ).checked,
+ "Colorway group button should be checked by default"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${SOFT_COLORWAY_THEME_ID}"]`
+ ).checked,
+ "Soft intensity should be checked by default"
+ );
+ },
+ undefined,
+ SOFT_COLORWAY_THEME_ID
+ );
+});
+
+/**
+ * Tests that a colorway available in the active collection is displayed in the
+ * colorway closet modal if the current theme is an expired colorway.
+ */
+add_task(async function colorwaycloset_modal_expired_colorway() {
+ const previousTheme = Services.prefs.getStringPref(
+ "extensions.activeThemeID"
+ );
+ info(`Previous theme is ${previousTheme}`);
+
+ await testInColorwayClosetModal(
+ async document => {
+ const {
+ colorwaySelector,
+ colorwayIntensities,
+ } = getColorwayClosetTestElements(document);
+
+ // Wait for colorway to load before checking modal UI
+ await waitForAddonEnabled(BALANCED_COLORWAY_THEME_ID);
+
+ ok(
+ colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ ).checked,
+ "Colorway group button should be checked by default"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ ).checked,
+ "Correct intensity should be checked by default"
+ );
+ },
+ [
+ SOFT_COLORWAY_THEME_ID,
+ BALANCED_COLORWAY_THEME_ID,
+ BOLD_COLORWAY_THEME_ID,
+ NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID,
+ ],
+ NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID
+ );
+});
+
+/**
+ * Tests that bold intensity is checked by default upon the opening the modal
+ * if the current theme has a dark scheme.
+ */
+add_task(async function colorwaycloset_modal_dark_scheme() {
+ await testInColorwayClosetModal(
+ async document => {
+ const {
+ colorwaySelector,
+ colorwayIntensities,
+ } = getColorwayClosetTestElements(document);
+
+ const previousTheme = Services.prefs.getStringPref(
+ "extensions.activeThemeID"
+ );
+ info(`Previous theme is ${previousTheme}`);
+
+ // Wait for theme to load before checking modal UI
+ await waitForAddonEnabled(BOLD_COLORWAY_THEME_ID);
+
+ ok(
+ colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ ).checked,
+ "Colorway group button should be checked by default"
+ );
+ ok(
+ colorwayIntensities.querySelector(
+ `input[value="${BOLD_COLORWAY_THEME_ID}"]`
+ ).checked,
+ "Bold intensity should be checked by default"
+ );
+ },
+ [
+ SOFT_COLORWAY_THEME_ID,
+ BALANCED_COLORWAY_THEME_ID,
+ BOLD_COLORWAY_THEME_ID,
+ MOCK_DARK_THEME_ID,
+ ],
+ MOCK_DARK_THEME_ID
+ );
+});
diff --git a/browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js b/browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js
new file mode 100644
index 0000000000..09fdd86766
--- /dev/null
+++ b/browser/components/colorways/tests/browser/browser_colorwayCloset_telemetry.js
@@ -0,0 +1,290 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+add_setup(async function setup_tests() {
+ Services.telemetry.clearEvents();
+});
+
+/**
+ * Tests that telemetry is registered when the Cancel button is selected on the Colorway Closet modal.
+ */
+add_task(async function colorwaycloset_modal_cancel() {
+ await testInColorwayClosetModal(async (document, contentWindow) => {
+ Services.telemetry.clearEvents();
+ registerCleanupFunction(() => {
+ Services.telemetry.clearEvents();
+ });
+
+ const { cancelButton } = getColorwayClosetTestElements(document);
+ let modalClosedPromise = BrowserTestUtils.waitForEvent(
+ contentWindow,
+ "unload",
+ "Waiting for modal to close"
+ );
+
+ cancelButton.click();
+ info("Closing modal");
+ await modalClosedPromise;
+ await waitForColorwaysTelemetryPromise();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "colorways_modal",
+ method: "cancel",
+ object: "modal",
+ },
+ ],
+ { category: "colorways_modal", object: "modal" }
+ );
+ Services.telemetry.clearEvents();
+ });
+});
+
+/**
+ * Tests that telemetry is registered when the Set Colorway button is selected on the Colorway Closet modal.
+ */
+add_task(async function colorwaycloset_modal_set_colorway() {
+ await testInColorwayClosetModal(async (document, contentWindow) => {
+ Services.telemetry.clearEvents();
+ registerCleanupFunction(() => {
+ Services.telemetry.clearEvents();
+ });
+
+ // Select colorway on modal
+ info("Selecting colorway radio button");
+ const {
+ colorwaySelector,
+ colorwayIntensities,
+ setColorwayButton,
+ } = getColorwayClosetTestElements(document);
+ const colorwayFamilyButton = colorwaySelector.querySelector(
+ `input[value="${BALANCED_COLORWAY_THEME_ID}"]`
+ );
+ colorwayFamilyButton.click();
+
+ // Select new intensity
+ info("Selecting new intensity button");
+ await BrowserTestUtils.waitForMutationCondition(
+ colorwayIntensities,
+ { subtree: true, attributeFilter: ["value"] },
+ () =>
+ colorwayIntensities.querySelector(
+ `input[value="${SOFT_COLORWAY_THEME_ID}"]`
+ ),
+ "Waiting for intensity button to be available"
+ );
+ let intensitiesChangedPromise = BrowserTestUtils.waitForEvent(
+ colorwayIntensities,
+ "change",
+ "Waiting for intensities change event"
+ );
+ let themeChangedPromise = BrowserTestUtils.waitForCondition(() => {
+ const activeTheme = Services.prefs.getStringPref(
+ "extensions.activeThemeID"
+ );
+ return activeTheme === SOFT_COLORWAY_THEME_ID;
+ }, "Waiting for the current theme to change after new intensity");
+
+ colorwayIntensities
+ .querySelector(`input[value="${SOFT_COLORWAY_THEME_ID}"]`)
+ .click();
+ await intensitiesChangedPromise;
+ await themeChangedPromise;
+
+ // Set colorway
+ info("Selecting set colorway button");
+ await BrowserTestUtils.waitForMutationCondition(
+ setColorwayButton,
+ { childList: true, attributeFilter: ["disabled"] },
+ () => !setColorwayButton.disabled,
+ "Waiting for set-colorway button to be available for selection"
+ );
+ let modalClosedPromise = BrowserTestUtils.waitForEvent(
+ contentWindow,
+ "unload",
+ "Waiting for modal to close"
+ );
+
+ setColorwayButton.click();
+ await waitForColorwaysTelemetryPromise();
+ info("Closing modal");
+ await modalClosedPromise;
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "colorways_modal",
+ method: "set_colorway",
+ object: "modal",
+ value: null,
+ extra: {
+ colorway_id: SOFT_COLORWAY_THEME_ID,
+ },
+ },
+ ],
+ { category: "colorways_modal", object: "modal" }
+ );
+
+ Services.telemetry.clearEvents();
+ });
+});
+
+/**
+ * Tests that telemetry is registered when the Set Colorway button is selected on the Colorway Closet modal,
+ * but for a selected colorway theme that does not have an intensity.
+ */
+add_task(async function colorwaycloset_modal_set_colorway_no_intensity() {
+ await testInColorwayClosetModal(
+ async (document, contentWindow) => {
+ Services.telemetry.clearEvents();
+ registerCleanupFunction(() => {
+ Services.telemetry.clearEvents();
+ });
+ const {
+ colorwaySelector,
+ setColorwayButton,
+ } = getColorwayClosetTestElements(document);
+
+ // Select colorway on modal
+ info("Selecting colorway radio button");
+ const colorwayFamilyButton = colorwaySelector.querySelector(
+ `input[value="${NO_INTENSITY_COLORWAY_THEME_ID}"]`
+ );
+ let themeChangedPromise = BrowserTestUtils.waitForCondition(() => {
+ const activeTheme = Services.prefs.getStringPref(
+ "extensions.activeThemeID"
+ );
+ return activeTheme === NO_INTENSITY_COLORWAY_THEME_ID;
+ }, "Waiting for the current theme to change after new intensity");
+ colorwayFamilyButton.click();
+ await themeChangedPromise;
+
+ // Set colorway
+ info("Selecting set colorway button");
+ await BrowserTestUtils.waitForMutationCondition(
+ setColorwayButton,
+ { childList: true, attributeFilter: ["disabled"] },
+ () => !setColorwayButton.disabled,
+ "Waiting for set-colorway button to be available for selection"
+ );
+ let modalClosedPromise = BrowserTestUtils.waitForEvent(
+ contentWindow,
+ "unload",
+ "Waiting for modal to close"
+ );
+
+ setColorwayButton.click();
+ await waitForColorwaysTelemetryPromise();
+ info("Closing modal");
+ await modalClosedPromise;
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "colorways_modal",
+ method: "set_colorway",
+ object: "modal",
+ value: null,
+ extra: {
+ colorway_id: NO_INTENSITY_COLORWAY_THEME_ID,
+ },
+ },
+ ],
+ { category: "colorways_modal", object: "modal" }
+ );
+
+ Services.telemetry.clearEvents();
+ },
+ [NO_INTENSITY_COLORWAY_THEME_ID]
+ );
+});
+
+/**
+ * Tests that telemetry is registered when the Firefox Home apply and undo buttons are selected on the Colorway Closet modal.
+ */
+add_task(async function colorwaycloset_modal_firefox_home() {
+ // Set homepage to NOT Firefox Home so that banner appears on the modal
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.startup.homepage", "about:blank"]],
+ });
+ await testInColorwayClosetModal(async document => {
+ Services.telemetry.clearEvents();
+ registerCleanupFunction(() => {
+ Services.telemetry.clearEvents();
+ });
+
+ // Ensure Firefox Home banner is visible first on the modal
+ const {
+ homepageResetContainer,
+ homepageResetApplyButton,
+ homepageResetUndoButton,
+ } = getColorwayClosetTestElements(document);
+
+ ok(homepageResetContainer, "Firefox Home banner is visible on the modal");
+ ok(homepageResetApplyButton, "Firefox Home Apply button should be visible");
+
+ homepageResetApplyButton.click();
+ await waitForColorwaysTelemetryPromise();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "colorways_modal",
+ method: "homepage_reset",
+ object: "modal",
+ },
+ ],
+ { category: "colorways_modal", object: "modal" }
+ );
+
+ ok(homepageResetUndoButton, "Firefox Home Undo button should be visible");
+
+ homepageResetUndoButton.click();
+ await waitForColorwaysTelemetryPromise();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "colorways_modal",
+ method: "homepage_reset_undo",
+ object: "modal",
+ },
+ ],
+ { category: "colorways_modal", object: "modal" }
+ );
+
+ Services.telemetry.clearEvents();
+ });
+});
+
+/**
+ * Tests that there is an event telemetry object "unknown" when no source is defined upon
+ * opening the modal.
+ */
+add_task(async function colorwaycloset_modal_unknown_source() {
+ await testInColorwayClosetModal(async document => {
+ // Since we already open the modal in testInColorwayClosetModal,
+ // do not clear telemetry events until after we verify
+ // the event with "unknown" source.
+ registerCleanupFunction(() => {
+ Services.telemetry.clearEvents();
+ });
+
+ await waitForColorwaysTelemetryPromise();
+ TelemetryTestUtils.assertNumberOfEvents(1, {
+ category: "colorways_modal",
+ object: "unknown",
+ });
+
+ Services.telemetry.clearEvents();
+ });
+});
diff --git a/browser/components/colorways/tests/browser/head.js b/browser/components/colorways/tests/browser/head.js
new file mode 100644
index 0000000000..0b8a687845
--- /dev/null
+++ b/browser/components/colorways/tests/browser/head.js
@@ -0,0 +1,364 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.import(
+ "resource://testing-common/AddonTestUtils.jsm"
+);
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+const { ColorwayClosetOpener } = ChromeUtils.import(
+ "resource:///modules/ColorwayClosetOpener.jsm"
+);
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+
+const MOCK_COLLECTION_TEST_CARD_IMAGE_PATH = "mockCollectionPreview.avif";
+const MOCK_THEME_NAME = "Mock Theme";
+const MOCK_THEME_NAME_2 = "Mock Theme 2";
+const MOCK_THEME_DESCRIPTION = "Mock Theme Description";
+const MOCK_THEME_DESCRIPTION_2 = "Mock Theme 2 Description";
+const MOCK_THEME_FIGURE_URL = "https://www.example.com/figure.avif";
+
+// fluent string ids
+const EXPIRY_DATE_L10N_ID = "colorway-collection-expiry-label";
+const MOCK_COLLECTION_L10N_TITLE = "mock-collection-l10n";
+const MOCK_COLLECTION_L10N_DESCRIPTION = "mock-collection-l10n-description";
+const MOCK_COLLECTION_L10N_SHORT_DESCRIPTION =
+ "mock-collection-l10n-short-description";
+
+// colorway theme ids
+const SOFT_COLORWAY_THEME_ID = "mocktheme-soft-colorway@mozilla.org";
+const BALANCED_COLORWAY_THEME_ID = "mocktheme-balanced-colorway@mozilla.org";
+const BOLD_COLORWAY_THEME_ID = "mocktheme-bold-colorway@mozilla.org";
+const SOFT_COLORWAY_THEME_ID_2 = "mocktheme2-soft-colorway@mozilla.org";
+const BALANCED_COLORWAY_THEME_ID_2 = "mocktheme2-balanced-colorway@mozilla.org";
+const BOLD_COLORWAY_THEME_ID_2 = "mocktheme2-bold-colorway@mozilla.org";
+const NO_INTENSITY_COLORWAY_THEME_ID = "mocktheme-colorway@mozilla.org";
+const NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID =
+ "expiredmocktheme-colorway@mozilla.org";
+
+// non-colorway theme ids
+const MOCK_DARK_THEME_ID = "mocktheme-dark@mozilla.org";
+
+// collections
+const MOCK_COLLECTION_ID = "mock-collection";
+const TEST_COLORWAY_COLLECTION = {
+ id: MOCK_COLLECTION_ID,
+ expiry: new Date("3000-01-01"),
+ l10nId: {
+ title: MOCK_COLLECTION_L10N_TITLE,
+ description: MOCK_COLLECTION_L10N_DESCRIPTION,
+ },
+ cardImagePath: MOCK_COLLECTION_TEST_CARD_IMAGE_PATH,
+};
+
+function installTestTheme(id) {
+ let name, description;
+ if (
+ [
+ SOFT_COLORWAY_THEME_ID_2,
+ BALANCED_COLORWAY_THEME_ID_2,
+ BOLD_COLORWAY_THEME_ID_2,
+ ].includes(id)
+ ) {
+ name = MOCK_THEME_NAME_2;
+ description = MOCK_THEME_DESCRIPTION_2;
+ } else {
+ name = MOCK_THEME_NAME;
+ description = MOCK_THEME_DESCRIPTION;
+ }
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name,
+ description,
+ browser_specific_settings: { gecko: { id } },
+ theme:
+ id === MOCK_DARK_THEME_ID
+ ? { properties: { color_scheme: "dark" } }
+ : {},
+ },
+ });
+ return AddonTestUtils.promiseInstallFile(xpi);
+}
+
+/**
+ * Creates stubs for BuiltInThemes
+ * @param {Boolean} hasActiveCollection false to create an expired collection; has default value of true to create an active collection
+ * @returns {Object} sinon sandbox containing all stubs for BuiltInThemes
+ * @see BuiltInThemes
+ */
+function initBuiltInThemesStubs(hasActiveCollection = true) {
+ info("Creating BuiltInThemes stubs");
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ info("Restoring BuiltInThemes sandbox for cleanup");
+ sandbox.restore();
+ });
+ sandbox.stub(BuiltInThemes, "getLocalizedColorwayGroupName").callsFake(id => {
+ if (
+ [
+ SOFT_COLORWAY_THEME_ID_2,
+ BALANCED_COLORWAY_THEME_ID_2,
+ BOLD_COLORWAY_THEME_ID_2,
+ ].includes(id)
+ ) {
+ return MOCK_THEME_NAME_2;
+ }
+
+ return MOCK_THEME_NAME;
+ });
+ sandbox
+ .stub(BuiltInThemes, "getLocalizedColorwayDescription")
+ .callsFake(id => {
+ if (
+ [
+ SOFT_COLORWAY_THEME_ID_2,
+ BALANCED_COLORWAY_THEME_ID_2,
+ BOLD_COLORWAY_THEME_ID_2,
+ ].includes(id)
+ ) {
+ return MOCK_THEME_DESCRIPTION_2;
+ }
+
+ return MOCK_THEME_DESCRIPTION;
+ });
+ sandbox.stub(BuiltInThemes.builtInThemeMap, "get").callsFake(id => {
+ let mockThemeProperties = {
+ collection: MOCK_COLLECTION_ID,
+ figureUrl: MOCK_THEME_FIGURE_URL,
+ };
+ if (id === NO_INTENSITY_EXPIRED_COLORWAY_THEME_ID) {
+ mockThemeProperties.expiry = new Date("1970-01-01");
+ }
+ return mockThemeProperties;
+ });
+
+ if (hasActiveCollection) {
+ sandbox
+ .stub(BuiltInThemes, "findActiveColorwayCollection")
+ .returns(TEST_COLORWAY_COLLECTION);
+ } else {
+ sandbox.stub(BuiltInThemes, "findActiveColorwayCollection").returns(null);
+ }
+
+ sandbox
+ .stub(BuiltInThemes, "isColorwayFromCurrentCollection")
+ .callsFake(id =>
+ [
+ SOFT_COLORWAY_THEME_ID,
+ BALANCED_COLORWAY_THEME_ID,
+ BOLD_COLORWAY_THEME_ID,
+ NO_INTENSITY_COLORWAY_THEME_ID,
+ SOFT_COLORWAY_THEME_ID_2,
+ BALANCED_COLORWAY_THEME_ID_2,
+ BOLD_COLORWAY_THEME_ID_2,
+ ].includes(id)
+ );
+ return sandbox.restore.bind(sandbox);
+}
+
+/**
+ * Creates stubs for ColorwayClosetOpener
+ * @returns {Object} sinon sandbox containing all stubs for ColorwayClosetOpener
+ * @see ColorwayClosetOpener
+ */
+function initColorwayClosetOpenerStubs() {
+ info("Creating ColorwayClosetOpener stubs");
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ info("Restoring ColorwayClosetOpener sandbox for cleanup");
+ sandbox.restore();
+ });
+ sandbox.stub(ColorwayClosetOpener, "openModal").resolves({});
+ return sandbox.restore.bind(sandbox);
+}
+
+function getColorwayClosetTestElements(document) {
+ return {
+ colorwayFigure: document.getElementById("colorway-figure"),
+ collectionTitle: document.getElementById("collection-title"),
+ expiryDateSpan: document.querySelector("#collection-expiry-date > span"),
+ colorwaySelector: document.querySelector("#colorway-selector"),
+ colorwayIntensities: document.querySelector("#colorway-intensity-radios"),
+ setColorwayButton: document.getElementById("set-colorway"),
+ cancelButton: document.getElementById("cancel"),
+ colorwayName: document.querySelector("#colorway-name"),
+ colorwayDescription: document.querySelector("#colorway-description"),
+ homepageResetContainer: document.getElementById("homepage-reset-container"),
+ homepageResetSuccessMessage: document.querySelector(
+ "#homepage-reset-success > span"
+ ),
+ homepageResetUndoButton: document.querySelector(
+ "#homepage-reset-success > button"
+ ),
+ homepageResetMessage: document.querySelector(
+ "#homepage-reset-prompt > span"
+ ),
+ homepageResetApplyButton: document.querySelector(
+ "#homepage-reset-prompt > button"
+ ),
+ };
+}
+
+function getColorwayClosetElementVisibility(document) {
+ const elements = getColorwayClosetTestElements(document);
+ let v = {};
+ for (const k in elements) {
+ const isVisible = BrowserTestUtils.is_visible(elements[k]);
+ v[k] = {
+ isVisible,
+ isHidden: !isVisible,
+ };
+ }
+ return v;
+}
+
+async function testInColorwayClosetModal(
+ testMethod,
+ themesToInstall = [
+ SOFT_COLORWAY_THEME_ID,
+ BALANCED_COLORWAY_THEME_ID,
+ BOLD_COLORWAY_THEME_ID,
+ ],
+ themeToEnable = undefined
+) {
+ const clearBuiltInThemesStubs = initBuiltInThemesStubs();
+ let themesToUninstall = [];
+ for (let theme of themesToInstall) {
+ info(`Installing ${theme}`);
+ const { addon } = await installTestTheme(theme);
+ // For some tests, we might want a colorway theme already enabled
+ // before opening the modal. If there is such a theme, be sure
+ // to enable it after installing all mock themes.
+ if (themeToEnable && themeToEnable === theme) {
+ info(`Enabling ${addon.id}`);
+ await addon.enable();
+ } else {
+ info(`Disabling ${addon.id}`);
+ await addon.disable();
+ }
+ themesToUninstall.push(addon);
+ }
+
+ const { closedPromise, dialog } = ColorwayClosetOpener.openModal();
+ await dialog._dialogReady;
+ const document = dialog._frame.contentDocument;
+ let contentWindow = dialog._frame.contentWindow;
+ try {
+ await testMethod(document, contentWindow);
+ } finally {
+ // If the window is still open after the test, close it.
+ contentWindow = dialog._frame.contentWindow;
+ if (contentWindow && !contentWindow.closed) {
+ info("Modal is still open. Closing modal before ending test.");
+ document.getElementById("cancel").click();
+ } else {
+ info("Modal is already closed");
+ }
+ await closedPromise;
+ for (let addon of themesToUninstall) {
+ info(`Uninstalling theme ${addon.id}`);
+ await addon.disable();
+ await addon.uninstall();
+ }
+ clearBuiltInThemesStubs();
+ // Clear any telemetry events recorded after closing the modal
+ Services.telemetry.clearEvents();
+ }
+}
+
+/**
+ * Registers mock Fluent locale strings for colorway collections.
+ */
+async function registerMockCollectionL10nIds() {
+ info("Register mock fluent locale strings");
+
+ let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tmpDir.append("l10n-colorwaycloset-mocks");
+
+ await IOUtils.makeDirectory(tmpDir.path, { ignoreExisting: true });
+ await IOUtils.writeUTF8(
+ PathUtils.join(tmpDir.path, "mock-colorways.ftl"),
+ [
+ `${MOCK_COLLECTION_L10N_TITLE} = Mock collection title`,
+ `${MOCK_COLLECTION_L10N_SHORT_DESCRIPTION} = Mock collection subheading`,
+ ].join("\n")
+ );
+
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+
+ resProto.setSubstitution(
+ "l10n-colorwaycloset-mocks",
+ Services.io.newFileURI(tmpDir)
+ );
+
+ let mockSource = new L10nFileSource(
+ "colorwayscloset-mocks",
+ "app",
+ ["en-US"],
+ "resource://l10n-colorwaycloset-mocks/"
+ );
+
+ let l10nReg = L10nRegistry.getInstance();
+ l10nReg.registerSources([mockSource]);
+
+ registerCleanupFunction(async () => {
+ l10nReg.removeSources([mockSource]);
+ resProto.setSubstitution("l10n-colorwaycloset-mocks", null);
+ info(`Clearing temporary directory ${tmpDir.path}`);
+ await IOUtils.remove(tmpDir.path, { recursive: true, ignoreAbsent: true });
+ });
+
+ // Confirm that the mock fluent resources are available as expected.
+ let bundles = l10nReg.generateBundles(["en-US"], ["mock-colorways.ftl"]);
+ let bundle0 = (await bundles.next()).value;
+ is(
+ bundle0.locales[0],
+ "en-US",
+ "Got the expected locale in the mock L10nFileSource"
+ );
+ ok(
+ bundle0.hasMessage(MOCK_COLLECTION_L10N_TITLE),
+ "Got the expected l10n id in the mock L10nFileSource"
+ );
+}
+
+/**
+ * Waits for an addon to be enabled.
+ * @param {String} addonId the string id of an addon
+ * @returns {Promise} resolved promise when the the specified addon is enabled
+ */
+function waitForAddonEnabled(addonId) {
+ return new Promise(resolve => {
+ let listener = {
+ onEnabled(enabledAddon) {
+ if (enabledAddon.id == addonId) {
+ AddonManager.removeAddonListener(listener);
+ info(`Addon ${addonId} enabled. Removing listener and resolving.`);
+ resolve();
+ }
+ },
+ };
+ info(`Adding onEnabled listener for "${addonId}`);
+ AddonManager.addAddonListener(listener);
+ });
+}
+
+/**
+ * Waits for a Colorways telemetry event to trigger.
+ * @returns {Promise} promise from BrowserTestUtils.waitForCondition
+ */
+async function waitForColorwaysTelemetryPromise() {
+ return BrowserTestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ let colorwayEvents = events.filter(e => e[1] === "colorways_modal");
+ return colorwayEvents && colorwayEvents.length;
+ }, "Waiting for Colorways events ping");
+}