diff --git a/browser/base/content/spotlight.html b/browser/base/content/spotlight.html index f930e0d10d8d..169ccf2eb0aa 100644 --- a/browser/base/content/spotlight.html +++ b/browser/base/content/spotlight.html @@ -23,6 +23,7 @@ + + diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html index 3dd3dfeb0637..bae9d655927b 100644 --- a/browser/components/firefoxview/firefoxview.html +++ b/browser/components/firefoxview/firefoxview.html @@ -18,6 +18,7 @@ + entry.result == "added").length; + MigrationUtils.notifyLoginsManuallyImported(quantity); + progress[ lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS ] = { @@ -672,17 +671,18 @@ export class MigrationWizardParent extends JSWindowActorParent { ) { for (let resourceType in MigrationUtils.resourceTypes) { // Normally, we check each possible resourceType to see if we have one or - // more corresponding resourceTypes in profileMigrationData. The exception - // is for Safari, where the migrator does not expose a PASSWORDS resource - // type, but we allow the user to express that they'd like to import - // passwords from it anyways. This is because the Safari migration flow is - // special, and allows the user to import passwords from a file exported - // from Safari. + // more corresponding resourceTypes in profileMigrationData. + // + // The exception is for passwords for Safari, and for Chrome on Windows, + // where we cannot import passwords automatically, but we allow the user + // to express that they'd like to import passwords from it anyways. We + // use this to determine whether or not to show guidance on how to + // manually import a passwords CSV file. if ( profileMigrationData & MigrationUtils.resourceTypes[resourceType] || - (migrator.constructor.key == lazy.SafariProfileMigrator?.key && - MigrationUtils.resourceTypes[resourceType] == - MigrationUtils.resourceTypes.PASSWORDS) + (MigrationUtils.resourceTypes[resourceType] == + MigrationUtils.resourceTypes.PASSWORDS && + migrator.showsManualPasswordImport) ) { availableResourceTypes.push(resourceType); } diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs index 4c9a034132b0..14fdb9f966fe 100644 --- a/browser/components/migration/MigratorBase.sys.mjs +++ b/browser/components/migration/MigratorBase.sys.mjs @@ -238,6 +238,16 @@ export class MigratorBase { return Promise.resolve(false); } + /** + * Subclasses should override this and return true if the source browser + * cannot have its passwords imported directly, and if there is a specialized + * flow through the wizard to walk the user through importing from a CSV + * file manually. + */ + get showsManualPasswordImport() { + return false; + } + /** * This method returns a number that is the bitwise OR of all resource * types that are available in aProfile. See MigrationUtils.resourceTypes diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs index fa9f0338f4d2..4ea92d4739e1 100644 --- a/browser/components/migration/SafariProfileMigrator.sys.mjs +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -655,6 +655,18 @@ export class SafariProfileMigrator extends MigratorBase { return false; } + /** + * For Safari on macOS, we show a specialized flow for importing passwords + * from a CSV file. + * + * @returns {boolean} + */ + get showsManualPasswordImport() { + // Since this migrator will only ever be used on macOS, all conditions are + // met and we can always return true. + return true; + } + get mainPreferencesPropertyList() { if (this._mainPreferencesPropertyList === undefined) { let file = FileUtils.getDir("UsrPrfs", []); diff --git a/browser/components/migration/content/migration-wizard-constants.mjs b/browser/components/migration/content/migration-wizard-constants.mjs index 81d51ecd0a0f..8bf4aa2442d5 100644 --- a/browser/components/migration/content/migration-wizard-constants.mjs +++ b/browser/components/migration/content/migration-wizard-constants.mjs @@ -20,6 +20,7 @@ export const MigrationWizardConstants = Object.freeze({ FILE_IMPORT_PROGRESS: "file-import-progress", SAFARI_PERMISSION: "safari-permission", SAFARI_PASSWORD_PERMISSION: "safari-password-permission", + CHROME_WINDOWS_PASSWORD_PERMISSION: "chrome-windows-password-permission", NO_BROWSERS_FOUND: "no-browsers-found", }), diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs index a7fbc2963652..dfd397b236fc 100644 --- a/browser/components/migration/content/migration-wizard.mjs +++ b/browser/components/migration/content/migration-wizard.mjs @@ -249,6 +249,24 @@ export class MigrationWizard extends HTMLElement { +
+

+ +
    +
  1. +
  2. +
  3. +
  4. +
+

+ +

+ + + + +
+

@@ -298,6 +316,9 @@ export class MigrationWizard extends HTMLElement { if (window.MozXULElement) { window.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); window.MozXULElement.insertFTLIfNeeded("browser/migrationWizard.ftl"); + window.MozXULElement.insertFTLIfNeeded( + "preview/migrationWizardChromeWindows.ftl" + ); } document.l10n.connectRoot(shadow); diff --git a/browser/components/migration/metrics.yaml b/browser/components/migration/metrics.yaml index a073c215cb9f..386a38d1e45b 100644 --- a/browser/components/migration/metrics.yaml +++ b/browser/components/migration/metrics.yaml @@ -232,6 +232,20 @@ browser.migration: expires: never telemetry_mirror: BrowserMigration_SafariPasswordFile_Wizard + chrome_password_file_wizard: + type: event + description: > + Recorded if the user is importing from Chrome, and was presented with + the page of the wizard requesting to import passwords from a file. This + currently only gets shown on Windows. + bugs: + - https://bugzil.la/1960560 + data_reviews: + - https://bugzil.la/1960560 + notification_emails: + - mconley@mozilla.com + expires: never + migration_started_wizard: type: event description: > diff --git a/browser/components/migration/tests/browser/browser.toml b/browser/components/migration/tests/browser/browser.toml index 40fbe11a1fd0..c086f1933596 100644 --- a/browser/components/migration/tests/browser/browser.toml +++ b/browser/components/migration/tests/browser/browser.toml @@ -8,6 +8,9 @@ tags = "os_integration" ["browser_aboutwelcome_behavior.js"] +["browser_chrome_windows_passwords.js"] +run-if = ["os == 'win'"] + ["browser_dialog_cancel_close.js"] ["browser_dialog_open.js"] diff --git a/browser/components/migration/tests/browser/browser_chrome_windows_passwords.js b/browser/components/migration/tests/browser/browser_chrome_windows_passwords.js new file mode 100644 index 000000000000..e162060ac159 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_chrome_windows_passwords.js @@ -0,0 +1,413 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); + +const TEST_FILE_PATH = getTestFilePath("dummy_file.csv"); + +// We use MockFilePicker to simulate a native file picker, and prepare it +// to return a dummy file pointed at TEST_FILE_PATH. The file at +// TEST_FILE_PATH is not required (nor expected) to exist. +const { MockFilePicker } = SpecialPowers; + +add_setup(async function () { + MockFilePicker.init(window.browsingContext); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * A helper function that does most of the heavy lifting for the tests in + * this file. Specfically, it takes care of: + * + * 1. Stubbing out the various hunks of the ChromeProfileMigrator in order + * to simulate a migration without actually performing one, since the + * migrator itself isn't being tested here. + * 2. Stubbing out parts of MigrationUtils and LoginCSVImport to have a + * consistent reporting on how many things are imported. + * 3. Setting up the MockFilePicker if expectsFilePicker is true to return + * the TEST_FILE_PATH. + * 4. Opens up the migration wizard, and chooses to import both BOOKMARKS + * and PASSWORDS, and then clicks "Import". + * 5. Waits for the migration wizard to show the Chrome password import + * instructions. + * 6. Runs taskFn + * 7. Closes the migration dialog. + * + * @param {boolean} expectsFilePicker + * True if the MockFilePicker should be set up to return TEST_FILE_PATH. + * @param {boolean} migrateBookmarks + * True if bookmarks should be migrated alongside passwords. If not, only + * passwords will be migrated. + * @param {boolean} shouldPasswordImportFail + * True if importing from the CSV file should fail. + * @param {Function} taskFn + * An asynchronous function that takes the following parameters in this + * order: + * + * {Element} wizard + * The opened migration wizard + * {Promise} filePickerShownPromise + * A Promise that resolves once the MockFilePicker has closed. This is + * undefined if expectsFilePicker was false. + * {object} importFromCSVStub + * The Sinon stub object for LoginCSVImport.importFromCSV. This can be + * used to check to see whether it was called. + * {Promise} didMigration + * A Promise that resolves to true once the migration completes. + * {Promise} wizardDone + * A Promise that resolves once the migration wizard reports that a + * migration has completed. + * @returns {Promise} + */ +async function testChromePasswordHelper( + expectsFilePicker, + migrateBookmarks, + shouldPasswordImportFail, + taskFn +) { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["chrome"]; + }); + + let migrator = new ChromeProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").resolves(migrator); + + sandbox + .stub(ChromeProfileMigrator.prototype, "getSourceProfiles") + .resolves([{ id: "chrome-test-1", name: "Chrome test profile 1" }]); + + // Have the migrator claim that only BOOKMARKS are available. + sandbox + .stub(ChromeProfileMigrator.prototype, "getMigrateData") + .resolves(MigrationUtils.resourceTypes.BOOKMARKS); + + let migrateStub; + let didMigration = new Promise(resolve => { + migrateStub = sandbox + .stub(ChromeProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + if (!migrateBookmarks) { + Assert.ok( + false, + "Should not have called migrate when only migrating Chrome passwords." + ); + } + + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + Assert.ok( + aResourceTypes & MigrationUtils.resourceTypes.BOOKMARKS, + "Should have requested to migrate the BOOKMARKS resource." + ); + Assert.ok( + !(aResourceTypes & MigrationUtils.resourceTypes.PASSWORDS), + "Should not have requested to migrate the PASSWORDS resource." + ); + + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS, true); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + // We'll pretend we added EXPECTED_QUANTITY passwords from the Chrome + // password file. + let results = []; + for (let i = 0; i < EXPECTED_QUANTITY; ++i) { + results.push({ result: "added" }); + } + let importFromCSVStub = sandbox.stub(LoginCSVImport, "importFromCSV"); + + if (shouldPasswordImportFail) { + importFromCSVStub.rejects(new Error("Some error message")); + } else { + importFromCSVStub.resolves(results); + } + + sandbox.stub(MigrationUtils, "_importQuantities").value({ + bookmarks: EXPECTED_QUANTITY, + }); + + let filePickerShownPromise; + + if (expectsFilePicker) { + MockFilePicker.reset(); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + } + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Chrome"); + let panelItem = shadow.querySelector( + `panel-item[key="${ChromeProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceTypeList = shadow.querySelector("#resource-type-list"); + + // Let's choose whether to import BOOKMARKS first. + let bookmarksNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS}"]` + ); + bookmarksNode.control.checked = migrateBookmarks; + + // Let's make sure that PASSWORDS is displayed despite the migrator only + // (currently) returning BOOKMARKS as an available resource to migrate. + let passwordsNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ); + Assert.ok( + !passwordsNode.hidden, + "PASSWORDS should be available to import from." + ); + passwordsNode.control.checked = true; + + let deck = shadow.querySelector("#wizard-deck"); + let switchedToChromePermissionPage = + BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + + MigrationWizardConstants.PAGES.CHROME_WINDOWS_PASSWORD_PERMISSION + ); + } + ); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + await switchedToChromePermissionPage; + Assert.ok(true, "Went to Chrome permission page after attempting import."); + + await taskFn( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + + doneButton.click(); + await dialogClosed; + }); + + sandbox.restore(); + MockFilePicker.reset(); +} + +/** + * Tests the flow of importing passwords from Chrome via an + * exported CSV file. + */ +add_task(async function test_chrome_password_do_import() { + await testChromePasswordHelper( + true, + true, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let manualPasswordImportSelect = shadow.querySelector( + "div[name='page-chrome-windows-password-permission'] .manual-password-import-select" + ); + + manualPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await didMigration; + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + } + ); +}); + +/** + * Tests that only passwords get imported if the user only opts + * to import passwords, and that nothing else gets imported. + */ +add_task(async function test_chrome_password_only_do_import() { + await testChromePasswordHelper( + true, + false, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let manualPasswordImportSelect = shadow.querySelector( + "div[name='page-chrome-windows-password-permission'] .manual-password-import-select" + ); + manualPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + Assert.ok( + !migrateStub.called, + "ChromeProfileMigrator.migrate was not called." + ); + } + ); +}); + +/** + * Tests the flow of importing passwords from Chrome when the file + * import fails. + */ +add_task(async function test_chrome_password_empty_csv_file() { + await testChromePasswordHelper( + true, + true, + true, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let manualPasswordImportSelect = shadow.querySelector( + "div[name='page-chrome-windows-password-permission'] .manual-password-import-select" + ); + manualPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await didMigration; + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + + await wizardDone; + + let headerL10nID = + shadow.querySelector("#progress-header").dataset.l10nId; + Assert.equal( + headerL10nID, + "migration-wizard-progress-done-with-warnings-header" + ); + + let progressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"` + ); + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = + progressGroup.querySelector(".message-text").textContent; + + Assert.equal( + progressIcon.getAttribute("state"), + "warning", + "Icon should be in the warning state." + ); + Assert.stringMatches( + messageText, + /file doesn’t include any valid password data/ + ); + } + ); +}); + +/** + * Tests that the user can skip importing passwords from Chrome. + */ +add_task(async function test_chrome_password_skip() { + await testChromePasswordHelper( + false, + true, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let manualPasswordImportSkip = shadow.querySelector( + "div[name='page-chrome-windows-password-permission'] .manual-password-import-skip" + ); + manualPasswordImportSkip.click(); + + await didMigration; + Assert.ok(!MockFilePicker.shown, "Never showed the file picker."); + Assert.ok( + !importFromCSVStub.called, + "Importing from CSV was never called." + ); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + } + ); +}); diff --git a/browser/components/storybook/stories/migration-wizard.stories.mjs b/browser/components/storybook/stories/migration-wizard.stories.mjs index 5d3fbd3958c6..ab69de33eba1 100644 --- a/browser/components/storybook/stories/migration-wizard.stories.mjs +++ b/browser/components/storybook/stories/migration-wizard.stories.mjs @@ -11,6 +11,10 @@ import { MigrationWizardConstants } from "chrome://browser/content/migration/mig // Imported for side-effects. import "toolkit-widgets/named-deck.js"; +window.MozXULElement.insertFTLIfNeeded( + "locales-preview/migrationWizardChromeWindows.ftl" +); + export default { title: "Domain-specific UI Widgets/Migration Wizard", component: "migration-wizard", @@ -592,6 +596,14 @@ SafariPasswordPermissions.args = { }, }; +export const ChromeWindowsPasswordPermissions = Template.bind({}); +ChromeWindowsPasswordPermissions.args = { + dialogMode: true, + state: { + page: MigrationWizardConstants.PAGES.CHROME_WINDOWS_PASSWORD_PERMISSION, + }, +}; + export const NoBrowsersFound = Template.bind({}); NoBrowsersFound.args = { dialogMode: true, diff --git a/browser/locales-preview/migrationWizardChromeWindows.ftl b/browser/locales-preview/migrationWizardChromeWindows.ftl new file mode 100644 index 000000000000..f31d06e52a44 --- /dev/null +++ b/browser/locales-preview/migrationWizardChromeWindows.ftl @@ -0,0 +1,13 @@ +# 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/. + +migration-chrome-windows-password-import-header = How to import passwords from Chrome +migration-chrome-windows-password-import-steps-header = In Chrome: +migration-chrome-windows-password-import-step1 = Open the main menu and go to Passwords and Autofill > Google Password Manager. +migration-chrome-windows-password-import-step2 = Select “Settings” from the menu. +migration-chrome-windows-password-import-step3 = Choose “Download file” and save it to your device. +migration-chrome-windows-password-import-step4 = Return here and “Select File” to finish import. + +migration-manual-password-import-skip-button = Skip +migration-manual-password-import-select-button = Select File diff --git a/browser/locales/jar.mn b/browser/locales/jar.mn index b00da64d18c6..4d1734cdd5de 100644 --- a/browser/locales/jar.mn +++ b/browser/locales/jar.mn @@ -20,6 +20,7 @@ preview/linkPreview.ftl (../locales-preview/linkPreview.ftl) preview/smartTabGroups.ftl (../locales-preview/smartTabGroups.ftl) preview/taskbartabs.ftl (../locales-preview/taskbartabs.ftl) + preview/migrationWizardChromeWindows.ftl (../locales-preview/migrationWizardChromeWindows.ftl) @AB_CD@.jar: % locale browser @AB_CD@ %locale/browser/ diff --git a/browser/themes/shared/jar.inc.mn b/browser/themes/shared/jar.inc.mn index c91878ad50f3..e1c7bf2ad2a7 100644 --- a/browser/themes/shared/jar.inc.mn +++ b/browser/themes/shared/jar.inc.mn @@ -77,6 +77,7 @@ skin/classic/browser/migration/migration-wizard.css (../shared/migration/migration-wizard.css) skin/classic/browser/migration/success.svg (../shared/migration/success.svg) skin/classic/browser/migration/progress-mask.svg (../shared/migration/progress-mask.svg) + skin/classic/browser/migration/chrome-icon-3dots.svg (../shared/migration/chrome-icon-3dots.svg) skin/classic/browser/migration/safari-icon-3dots.svg (../shared/migration/safari-icon-3dots.svg) skin/classic/browser/notification-icons/autoplay-media.svg (../shared/notification-icons/autoplay-media.svg) skin/classic/browser/notification-icons/autoplay-media-blocked.svg (../shared/notification-icons/autoplay-media-blocked.svg) diff --git a/browser/themes/shared/migration/chrome-icon-3dots.svg b/browser/themes/shared/migration/chrome-icon-3dots.svg new file mode 100644 index 000000000000..1974f916750a --- /dev/null +++ b/browser/themes/shared/migration/chrome-icon-3dots.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/themes/shared/migration/migration-wizard.css b/browser/themes/shared/migration/migration-wizard.css index f3019f751044..9c24eb8a37de 100644 --- a/browser/themes/shared/migration/migration-wizard.css +++ b/browser/themes/shared/migration/migration-wizard.css @@ -340,16 +340,30 @@ summary { vertical-align: middle; } -.safari-icon-3dots { +.safari-icon-3dots, +.chrome-icon-3dots { width: 16px; height: 16px; - vertical-align: middle; - content: url("chrome://browser/skin/migration/safari-icon-3dots.svg"); -moz-context-properties: fill, stroke; + vertical-align: middle; +} + +.safari-icon-3dots { + content: url("chrome://browser/skin/migration/safari-icon-3dots.svg"); fill: currentColor; stroke: color-mix(in srgb, currentColor 10%, transparent 90%); } +.chrome-icon-3dots { + content: url("chrome://browser/skin/migration/chrome-icon-3dots.svg"); + /** + * Stroke and fill colours were sampled from built-in dark and light theme + * from Chrome on Windows + */ + fill: light-dark(#474747, #C7C7C7); + stroke: light-dark(#474747, #C7C7C7); +} + .no-browsers-found-message { display: flex; }