Bug 1762298 - Inherit used color-scheme from embedder <browser> elements. r=nika,dao,Gijs
authorEmilio Cobos Álvarez <emilio@crisal.io>
Mon, 04 Apr 2022 18:22:04 +0000
changeset 613315 5f2dedfd64a69525ff159f3e1b432339af9d0c17
parent 613314 865d5128459a64f04b54ff9e85daf5624631a0fa
child 613316 8b11b00d98c23582cff9a255dc043f22ff82ec8b
push id39521
push userabutkovits@mozilla.com
push dateTue, 05 Apr 2022 09:40:56 +0000
treeherdermozilla-central@8d8a4fb25517 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnika, dao, Gijs
bugs1762298
milestone100.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1762298 - Inherit used color-scheme from embedder <browser> elements. r=nika,dao,Gijs This allows popups and sidebars to use the chrome preferred color-scheme. This moves the responsibility of setting the content-preferred color scheme to the appropriate browsers to the front-end (via tabs.css). We still return the PreferredColorSchemeForContent() when there's no pres context (e.g., for display:none in-process iframes). We could potentially move a bunch of the pres-context data to the document instead, but that should be acceptable IMO as for general web content there's no behavior change in any case. Differential Revision: https://phabricator.services.mozilla.com/D142578
browser/themes/shared/tabs.css
docshell/base/BrowsingContext.cpp
docshell/base/BrowsingContext.h
docshell/base/CanonicalBrowsingContext.cpp
dom/base/Document.cpp
dom/base/nsFrameLoader.cpp
image/test/mochitest/test_mq_dynamic_svg.html
layout/base/nsPresContext.cpp
layout/base/nsPresContext.h
layout/base/tests/chrome/chrome.ini
layout/base/tests/chrome/test_color_scheme_browser.xhtml
layout/generic/nsContainerFrame.cpp
layout/generic/nsSubDocumentFrame.cpp
layout/generic/nsSubDocumentFrame.h
layout/style/test/test_non_content_accessible_env_vars.html
servo/components/style/custom_properties.rs
toolkit/components/thumbnails/BackgroundPageThumbs.jsm
xpcom/ds/StaticAtoms.py
--- a/browser/themes/shared/tabs.css
+++ b/browser/themes/shared/tabs.css
@@ -12,16 +12,20 @@
   --tab-border-radius: 4px;
   --tab-shadow-max-size: 6px;
   --tab-block-margin: 4px;
   /* --tabpanel-background-color matches $in-content-page-background in newtab
      (browser/components/newtab/content-src/styles/_variables.scss) */
   --tabpanel-background-color: #F9F9FB;
 }
 
+#tabbrowser-tabpanels browser {
+  color-scheme: env(-moz-content-preferred-color-scheme);
+}
+
 @media (-moz-content-prefers-color-scheme: dark) {
   :root {
     /* --tabpanel-background-color matches $in-content-page-background in newtab
        (browser/components/newtab/content-src/styles/_variables.scss) */
     --tabpanel-background-color: #2B2A33;
   }
 }
 
--- a/docshell/base/BrowsingContext.cpp
+++ b/docshell/base/BrowsingContext.cpp
@@ -698,18 +698,18 @@ void BrowsingContext::SetEmbedderElement
         if (!aEmbedder->AttrValueIs(kNameSpaceID_None,
                                     nsGkAtoms::initiallyactive,
                                     nsGkAtoms::_false, eIgnoreCase)) {
           txn.SetExplicitActive(ExplicitActiveStatus::Active);
         }
       }
       txn.SetMessageManagerGroup(messageManagerGroup);
 
-      bool useGlobalHistory = !aEmbedder->HasAttr(
-          kNameSpaceID_None, nsGkAtoms::disableglobalhistory);
+      bool useGlobalHistory =
+          !aEmbedder->HasAttr(nsGkAtoms::disableglobalhistory);
       txn.SetUseGlobalHistory(useGlobalHistory);
     }
 
     MOZ_ALWAYS_SUCCEEDS(txn.Commit(this));
   }
 
   if (XRE_IsParentProcess() && IsTopContent()) {
     Canonical()->MaybeSetPermanentKey(aEmbedder);
@@ -2776,44 +2776,40 @@ bool BrowsingContext::InactiveForSuspend
   return !IsActive() && GetPageAwakeRequestCount() == 0;
 }
 
 bool BrowsingContext::CanSet(FieldIndex<IDX_TouchEventsOverrideInternal>,
                              dom::TouchEventsOverride, ContentParent* aSource) {
   return XRE_IsParentProcess() && !aSource;
 }
 
+void BrowsingContext::DidSet(FieldIndex<IDX_EmbedderColorScheme>,
+                             dom::PrefersColorSchemeOverride aOldValue) {
+  if (GetEmbedderColorScheme() == aOldValue) {
+    return;
+  }
+  PresContextAffectingFieldChanged();
+}
+
 void BrowsingContext::DidSet(FieldIndex<IDX_PrefersColorSchemeOverride>,
                              dom::PrefersColorSchemeOverride aOldValue) {
   MOZ_ASSERT(IsTop());
   if (PrefersColorSchemeOverride() == aOldValue) {
     return;
   }
-  PreOrderWalk([&](BrowsingContext* aContext) {
-    if (nsIDocShell* shell = aContext->GetDocShell()) {
-      if (nsPresContext* pc = shell->GetPresContext()) {
-        pc->RecomputeBrowsingContextDependentData();
-      }
-    }
-  });
+  PresContextAffectingFieldChanged();
 }
 
 void BrowsingContext::DidSet(FieldIndex<IDX_MediumOverride>,
                              nsString&& aOldValue) {
   MOZ_ASSERT(IsTop());
   if (GetMediumOverride() == aOldValue) {
     return;
   }
-  PreOrderWalk([&](BrowsingContext* aContext) {
-    if (nsIDocShell* shell = aContext->GetDocShell()) {
-      if (nsPresContext* pc = shell->GetPresContext()) {
-        pc->RecomputeBrowsingContextDependentData();
-      }
-    }
-  });
+  PresContextAffectingFieldChanged();
 }
 
 void BrowsingContext::DidSet(FieldIndex<IDX_DisplayMode>,
                              enum DisplayMode aOldValue) {
   MOZ_ASSERT(IsTop());
 
   if (GetDisplayMode() == aOldValue) {
     return;
@@ -2873,17 +2869,20 @@ bool BrowsingContext::CanSet(FieldIndex<
   return XRE_IsParentProcess() && !aSource && IsTop();
 }
 
 void BrowsingContext::DidSet(FieldIndex<IDX_OverrideDPPX>, float aOldValue) {
   MOZ_ASSERT(IsTop());
   if (GetOverrideDPPX() == aOldValue) {
     return;
   }
-
+  PresContextAffectingFieldChanged();
+}
+
+void BrowsingContext::PresContextAffectingFieldChanged() {
   PreOrderWalk([&](BrowsingContext* aContext) {
     if (nsIDocShell* shell = aContext->GetDocShell()) {
       if (nsPresContext* pc = shell->GetPresContext()) {
         pc->RecomputeBrowsingContextDependentData();
       }
     }
   });
 }
--- a/docshell/base/BrowsingContext.h
+++ b/docshell/base/BrowsingContext.h
@@ -205,18 +205,22 @@ enum class ExplicitActiveStatus : uint8_
    * documents. */                                                            \
   FIELD(HasLoadedNonInitialDocument, bool)                                    \
   /* Default value for nsIContentViewer::authorStyleDisabled in any new       \
    * browsing contexts created as a descendant of this one.  Valid only for   \
    * top BCs. */                                                              \
   FIELD(AuthorStyleDisabledDefault, bool)                                     \
   FIELD(ServiceWorkersTestingEnabled, bool)                                   \
   FIELD(MediumOverride, nsString)                                             \
-  FIELD(PrefersColorSchemeOverride, mozilla::dom::PrefersColorSchemeOverride) \
-  FIELD(DisplayMode, mozilla::dom::DisplayMode)                               \
+  /* DevTools override for prefers-color-scheme */                            \
+  FIELD(PrefersColorSchemeOverride, dom::PrefersColorSchemeOverride)          \
+  /* prefers-color-scheme override based on the color-scheme style of our     \
+   * <browser> embedder element. */                                           \
+  FIELD(EmbedderColorScheme, dom::PrefersColorSchemeOverride)                 \
+  FIELD(DisplayMode, dom::DisplayMode)                                        \
   /* The number of entries added to the session history because of this       \
    * browsing context. */                                                     \
   FIELD(HistoryEntryCount, uint32_t)                                          \
   /* Don't use the getter of the field, but IsInBFCache() method */           \
   FIELD(IsInBFCache, bool)                                                    \
   FIELD(HasRestoreData, bool)                                                 \
   FIELD(SessionStoreEpoch, uint32_t)                                          \
   /* Whether we can execute scripts in this BrowsingContext. Has no effect    \
@@ -1012,26 +1016,36 @@ class BrowsingContext : public nsILoadCo
               ContentParent*) {
     return IsTop();
   }
 
   bool CanSet(FieldIndex<IDX_MediumOverride>, const nsString&, ContentParent*) {
     return IsTop();
   }
 
+  bool CanSet(FieldIndex<IDX_EmbedderColorScheme>,
+              dom::PrefersColorSchemeOverride, ContentParent* aSource) {
+    return CheckOnlyEmbedderCanSet(aSource);
+  }
+
   bool CanSet(FieldIndex<IDX_PrefersColorSchemeOverride>,
               dom::PrefersColorSchemeOverride, ContentParent*) {
     return IsTop();
   }
 
   void DidSet(FieldIndex<IDX_InRDMPane>, bool aOldValue);
 
+  void DidSet(FieldIndex<IDX_EmbedderColorScheme>,
+              dom::PrefersColorSchemeOverride aOldValue);
+
   void DidSet(FieldIndex<IDX_PrefersColorSchemeOverride>,
               dom::PrefersColorSchemeOverride aOldValue);
 
+  void PresContextAffectingFieldChanged();
+
   void DidSet(FieldIndex<IDX_MediumOverride>, nsString&& aOldValue);
 
   bool CanSet(FieldIndex<IDX_SuspendMediaWhenInactive>, bool, ContentParent*) {
     return IsTop();
   }
 
   bool CanSet(FieldIndex<IDX_TouchEventsOverrideInternal>,
               dom::TouchEventsOverride aTouchEventsOverride,
--- a/docshell/base/CanonicalBrowsingContext.cpp
+++ b/docshell/base/CanonicalBrowsingContext.cpp
@@ -301,16 +301,17 @@ void CanonicalBrowsingContext::ReplacedB
 
   // Use the Transaction for the fields which need to be updated whether or not
   // the new context has been attached before.
   // SetWithoutSyncing can be used if context hasn't been attached.
   Transaction txn;
   txn.SetBrowserId(GetBrowserId());
   txn.SetHistoryID(GetHistoryID());
   txn.SetExplicitActive(GetExplicitActive());
+  txn.SetEmbedderColorScheme(GetEmbedderColorScheme());
   txn.SetHasRestoreData(GetHasRestoreData());
   txn.SetShouldDelayMediaFromStart(GetShouldDelayMediaFromStart());
   // As this is a different BrowsingContext, set InitialSandboxFlags to the
   // current flags in the new context so that they also apply to any initial
   // about:blank documents created in it.
   txn.SetSandboxFlags(GetSandboxFlags());
   txn.SetInitialSandboxFlags(GetSandboxFlags());
   if (aNewContext->EverAttached()) {
--- a/dom/base/Document.cpp
+++ b/dom/base/Document.cpp
@@ -17736,17 +17736,17 @@ ColorScheme Document::DefaultColorScheme
 
 ColorScheme Document::PreferredColorScheme(IgnoreRFP aIgnoreRFP) const {
   if (aIgnoreRFP == IgnoreRFP::No &&
       nsContentUtils::ShouldResistFingerprinting(this)) {
     return ColorScheme::Light;
   }
 
   if (nsPresContext* pc = GetPresContext()) {
-    if (auto scheme = pc->GetOverriddenColorScheme()) {
+    if (auto scheme = pc->GetOverriddenOrEmbedderColorScheme()) {
       return *scheme;
     }
   }
 
   // NOTE(emilio): We use IsInChromeDocShell rather than IsChromeDoc
   // intentionally, to make chrome documents in content docshells (like about
   // pages) use the content color scheme.
   if (IsInChromeDocShell()) {
--- a/dom/base/nsFrameLoader.cpp
+++ b/dom/base/nsFrameLoader.cpp
@@ -2652,41 +2652,63 @@ bool nsFrameLoader::TryRemoteBrowserInte
   // out of process iframes also get to skip this check.
   if (!OwnerIsMozBrowserFrame() && !XRE_IsContentProcess()) {
     if (parentDocShell->ItemType() != nsIDocShellTreeItem::typeChrome) {
       // Allow three exceptions to this rule :
       // - about:addons so it can load remote extension options pages
       // - about:preferences (in Thunderbird only) so it can load remote
       //     extension options pages for FileLink providers
       // - DevTools webext panels if DevTools is loaded in a content frame
+      // - Chrome mochitests can also do this.
       //
       // Note that the new frame's message manager will not be a child of the
       // chrome window message manager, and, the values of window.top and
       // window.parent will be different than they would be for a non-remote
       // frame.
       nsIURI* parentURI = parentWin->GetDocumentURI();
       if (!parentURI) {
         return false;
       }
 
       nsAutoCString specIgnoringRef;
       if (NS_FAILED(parentURI->GetSpecIgnoringRef(specIgnoringRef))) {
         return false;
       }
 
-      if (!(specIgnoringRef.EqualsLiteral("about:addons") ||
-            specIgnoringRef.EqualsLiteral(
-                "chrome://mozapps/content/extensions/aboutaddons.html") ||
+      const bool allowed = [&] {
+        const nsLiteralCString kAllowedURIs[] = {
+            "about:addons"_ns,
+            "chrome://mozapps/content/extensions/aboutaddons.html"_ns,
 #ifdef MOZ_THUNDERBIRD
-            specIgnoringRef.EqualsLiteral("about:3pane") ||
-            specIgnoringRef.EqualsLiteral("about:message") ||
-            specIgnoringRef.EqualsLiteral("about:preferences") ||
+            "about:3pane"_ns,
+            "about:message"_ns,
+            "about:preferences"_ns,
 #endif
-            specIgnoringRef.EqualsLiteral(
-                "chrome://browser/content/webext-panels.xhtml"))) {
+            "chrome://browser/content/webext-panels.xhtml"_ns,
+        };
+
+        for (const auto& allowedURI : kAllowedURIs) {
+          if (specIgnoringRef.Equals(allowedURI)) {
+            return true;
+          }
+        }
+
+        if (xpc::IsInAutomation() &&
+            StringBeginsWith(specIgnoringRef,
+                             "chrome://mochitests/content/chrome/"_ns)) {
+          return true;
+        }
+        return false;
+      }();
+
+      if (!allowed) {
+        NS_WARNING(
+            nsPrintfCString("Forbidden remote frame from content docshell %s",
+                            specIgnoringRef.get())
+                .get());
         return false;
       }
     }
 
     if (!mOwnerContent->IsXULElement()) {
       return false;
     }
 
--- a/image/test/mochitest/test_mq_dynamic_svg.html
+++ b/image/test/mochitest/test_mq_dynamic_svg.html
@@ -15,22 +15,35 @@ let f1 = window.f1;
 let f2 = window.f2;
 
 function snapshotsEqual() {
   let s1 = snapshotWindow(f1.contentWindow);
   let s2 = snapshotWindow(f2.contentWindow);
   return compareSnapshots(s1, s2, true)[0];
 }
 
+function waitForColorSchemeToBe(scheme) {
+  return new Promise(resolve => {
+    let mq = matchMedia(`(prefers-color-scheme: ${scheme})`);
+    if (mq.matches) {
+      resolve();
+    } else {
+      mq.addEventListener("change", resolve, { once: true });
+    }
+  });
+}
+
 async function run() {
   let loadedFrame1 = new Promise(resolve => f1.onload = resolve);
   let loadedFrame2 = new Promise(resolve => f2.onload = resolve);
   await SpecialPowers.pushPrefEnv({ set: [["layout.css.prefers-color-scheme.content-override", 1]] });
+  await waitForColorSchemeToBe("light");
   f1.src = "mq_dynamic_svg_test.html";
   f2.src = "mq_dynamic_svg_ref.html";
   await loadedFrame1;
   await loadedFrame2;
   ok(!snapshotsEqual(), "In light mode snapshot comparison should be false");
   await SpecialPowers.pushPrefEnv({ set: [["layout.css.prefers-color-scheme.content-override", 0]] });
+  await waitForColorSchemeToBe("dark");
   ok(snapshotsEqual(), "In dark mode snapshot comparison should be true");
   SimpleTest.finish();
 }
 </script>
--- a/layout/base/nsPresContext.cpp
+++ b/layout/base/nsPresContext.cpp
@@ -280,17 +280,17 @@ nsPresContext::nsPresContext(dom::Docume
       mQuirkSheetAdded(false),
       mHadNonBlankPaint(false),
       mHadContentfulPaint(false),
       mHadNonTickContentfulPaint(false),
       mHadContentfulPaintComposite(false),
 #ifdef DEBUG
       mInitialized(false),
 #endif
-      mColorSchemeOverride(dom::PrefersColorSchemeOverride::None) {
+      mOverriddenOrEmbedderColorScheme(dom::PrefersColorSchemeOverride::None) {
 #ifdef DEBUG
   PodZero(&mLayoutPhaseCount);
 #endif
 
   if (!IsDynamic()) {
     mImageAnimationMode = imgIContainer::kDontAnimMode;
     mNeverAnimate = true;
   } else {
@@ -864,22 +864,22 @@ void nsPresContext::AttachPresShell(mozi
       mImageAnimationMode = mImageAnimationModePref;
     else
       mImageAnimationMode = imgIContainer::kNormalAnimMode;
   }
 
   UpdateCharSet(doc->GetDocumentCharacterSet());
 }
 
-Maybe<ColorScheme> nsPresContext::GetOverriddenColorScheme() const {
+Maybe<ColorScheme> nsPresContext::GetOverriddenOrEmbedderColorScheme() const {
   if (IsPrintingOrPrintPreview()) {
     return Some(ColorScheme::Light);
   }
 
-  switch (mColorSchemeOverride) {
+  switch (mOverriddenOrEmbedderColorScheme) {
     case dom::PrefersColorSchemeOverride::Dark:
       return Some(ColorScheme::Dark);
     case dom::PrefersColorSchemeOverride::Light:
       return Some(ColorScheme::Light);
     case dom::PrefersColorSchemeOverride::None:
     case dom::PrefersColorSchemeOverride::EndGuard_:
       break;
   }
@@ -900,30 +900,43 @@ void nsPresContext::RecomputeBrowsingCon
     // as a result of the zoom on the embedder document so it doesn't really
     // matter... Medium also doesn't affect those.
     return;
   }
   SetFullZoom(browsingContext->FullZoom());
   SetTextZoom(browsingContext->TextZoom());
   SetOverrideDPPX(browsingContext->OverrideDPPX());
 
-  auto oldOverride = mColorSchemeOverride;
-  mColorSchemeOverride = browsingContext->Top()->PrefersColorSchemeOverride();
-  if (oldOverride != mColorSchemeOverride) {
+  auto oldScheme = mDocument->PreferredColorScheme();
+  auto* top = browsingContext->Top();
+  mOverriddenOrEmbedderColorScheme = [&] {
+    auto overriden = top->PrefersColorSchemeOverride();
+    if (overriden != PrefersColorSchemeOverride::None) {
+      return overriden;
+    }
+    for (auto* cur = browsingContext; cur; cur = cur->GetParent()) {
+      auto embedder = cur->GetEmbedderColorScheme();
+      if (embedder != PrefersColorSchemeOverride::None) {
+        return embedder;
+      }
+    }
+    return PrefersColorSchemeOverride::None;
+  }();
+
+  if (mDocument->PreferredColorScheme() != oldScheme) {
     // We need to restyle because not only media queries have changed, system
     // colors may as well via the prefers-color-scheme meta tag / effective
     // color-scheme property value.
     MediaFeatureValuesChanged({RestyleHint::RecascadeSubtree(), nsChangeHint(0),
                                MediaFeatureChangeReason::SystemMetricsChange},
                               MediaFeatureChangePropagation::JustThisDocument);
   }
 
   if (doc == mDocument) {
     // Medium doesn't apply to resource documents, etc.
-    auto* top = browsingContext->Top();
     RefPtr<nsAtom> mediumToEmulate;
     if (MOZ_UNLIKELY(!top->GetMediumOverride().IsEmpty())) {
       nsAutoString lower;
       nsContentUtils::ASCIIToLower(top->GetMediumOverride(), lower);
       mediumToEmulate = NS_Atomize(lower);
     }
     EmulateMedium(mediumToEmulate);
   }
--- a/layout/base/nsPresContext.h
+++ b/layout/base/nsPresContext.h
@@ -575,21 +575,21 @@ class nsPresContext : public nsISupports
    * Device full zoom differs from full zoom because it gets the zoom from
    * the device context, which may be using a different zoom due to rounding
    * of app units to device pixels.
    */
   float GetDeviceFullZoom();
 
   float GetOverrideDPPX() const { return mMediaEmulationData.mDPPX; }
 
-  // Gets the forced color-scheme if any via either DevTools emulation or
-  // printing.
+  // Gets the forced color-scheme if any via either our embedder, or DevTools
+  // emulation, or printing.
   //
   // NOTE(emilio): This might be called from an stylo thread.
-  Maybe<mozilla::ColorScheme> GetOverriddenColorScheme() const;
+  Maybe<mozilla::ColorScheme> GetOverriddenOrEmbedderColorScheme() const;
 
   /**
    * Recomputes the data dependent on the browsing context, like zoom and text
    * zoom.
    */
   void RecomputeBrowsingContextDependentData();
 
   mozilla::CSSCoord GetAutoQualityMinFontSize() const {
@@ -1372,17 +1372,17 @@ class nsPresContext : public nsISupports
 
 #ifdef DEBUG
   unsigned mInitialized : 1;
 #endif
 
   // FIXME(emilio): These would be better packed on top of the bitfields, but
   // that breaks bindgen in win32.
   FontVisibility mFontVisibility = FontVisibility::Unknown;
-  mozilla::dom::PrefersColorSchemeOverride mColorSchemeOverride;
+  mozilla::dom::PrefersColorSchemeOverride mOverriddenOrEmbedderColorScheme;
 
  protected:
   virtual ~nsPresContext();
 
   void LastRelease();
 
   nsITheme* EnsureTheme();
 
--- a/layout/base/tests/chrome/chrome.ini
+++ b/layout/base/tests/chrome/chrome.ini
@@ -89,16 +89,17 @@ skip-if = os == 'linux' && !debug # Bug 
 [test_bug812817.xhtml]
 [test_bug1018265.xhtml]
 [test_bug1041200.xhtml]
 skip-if = os == 'win' && bits == 64 # Bug 1272321
 support-files =
   bug1041200_frame.html
   bug1041200_window.html
 [test_chrome_content_integration.xhtml]
+[test_color_scheme_browser.xhtml]
 [test_default_background.xhtml]
 [test_dialog_with_positioning.html]
 tags = openwindow
 [test_fixed_bg_scrolling_repaints.html]
 [test_prerendered_transforms.html]
 [test_printer_default_settings.html]
 [test_printpreview.xhtml]
 skip-if = (os == "linux" && bits == 32) || (verify && (os == 'win')) # Disabled on Linux32 for bug 1278957
new file mode 100644
--- /dev/null
+++ b/layout/base/tests/chrome/test_color_scheme_browser.xhtml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+      windowtype="Toolkit:PictureInPicture"
+      chromemargin="0,0,0,0">
+  <head>
+    <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+    <script src="chrome://mochikit/content/chrome-harness.js"></script>
+    <style>
+      #light { color-scheme: light }
+      #dark { color-scheme: dark }
+    </style>
+  </head>
+  <body>
+    <div id="dynamic-test">
+      <xul:browser type="content" remote="true" src="about:blank" class="remote" />
+      <xul:browser type="content" src="about:blank" class="nonremote" />
+    </div>
+    <div id="light">
+      <xul:browser type="content" remote="true" src="about:blank" class="remote" />
+      <xul:browser type="content" src="about:blank" class="nonremote" />
+    </div>
+    <div id="dark">
+      <xul:browser type="content" remote="true" src="about:blank" class="remote" />
+      <xul:browser type="content" src="about:blank" class="nonremote" />
+    </div>
+    <script><![CDATA[
+      async function getBrowserColorScheme(browser) {
+        return SpecialPowers.spawn(browser, [], () => {
+          return content.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+        });
+      }
+      async function tick() {
+        return new Promise(resolve => {
+          requestAnimationFrame(() => requestAnimationFrame(resolve));
+        });
+      }
+      async function testElement(id, expected) {
+        let element = document.getElementById(id);
+        for (let browser of element.querySelectorAll("browser")) {
+          let scheme = await getBrowserColorScheme(browser);
+          is(scheme, expected, `${id}: ${browser.className} should be ${expected}`);
+        }
+      }
+      add_task(async function test_browser_color_scheme() {
+        for (let id of ["dynamic-test", "light", "dark"]) {
+          let expected = id == "dynamic-test"
+                ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
+                : id;
+          await testElement(id, expected);
+        }
+      });
+
+      add_task(async function test_browser_color_scheme_dynamic_style() {
+        let dynamicTest = document.getElementById("dynamic-test");
+        for (let value of ["light", "dark"]) {
+          await tick();
+          dynamicTest.style.colorScheme = value;
+          await testElement("dynamic-test", value);
+        }
+        dynamicTest.style.colorScheme = "";
+        await tick();
+      });
+
+      add_task(async function test_browser_color_scheme_dynamic_system() {
+        for (let dark of [true, false]) {
+          await SpecialPowers.pushPrefEnv({ set: [["ui.systemUsesDarkTheme", dark ? 1 : 0]] });
+          await tick();
+          await testElement("dynamic-test", dark ? "dark" : "light");
+          await SpecialPowers.popPrefEnv();
+        }
+      });
+    ]]></script>
+  </body>
+</html>
+
--- a/layout/generic/nsContainerFrame.cpp
+++ b/layout/generic/nsContainerFrame.cpp
@@ -774,17 +774,17 @@ void nsContainerFrame::SyncWindowPropert
     nsTransparencyMode mode =
         nsLayoutUtils::GetFrameTransparency(aFrame, rootFrame);
     StyleWindowShadow shadow = rootFrame->StyleUIReset()->mWindowShadow;
     nsCOMPtr<nsIWidget> viewWidget = aView->GetWidget();
     viewWidget->SetTransparencyMode(mode);
     windowWidget->SetWindowShadowStyle(shadow);
 
     // For macOS, apply color scheme overrides to the top level window widget.
-    if (auto scheme = aPresContext->GetOverriddenColorScheme()) {
+    if (auto scheme = aPresContext->GetOverriddenOrEmbedderColorScheme()) {
       windowWidget->SetColorScheme(scheme);
     }
   }
 
   if (!aRC) return;
 
   if (!weak.IsAlive()) {
     return;
--- a/layout/generic/nsSubDocumentFrame.cpp
+++ b/layout/generic/nsSubDocumentFrame.cpp
@@ -198,16 +198,18 @@ void nsSubDocumentFrame::ShowViewer() {
     }
     mCallingShow = false;
     mDidCreateDoc = didCreateDoc;
 
     if (!HasAnyStateBits(NS_FRAME_FIRST_REFLOW)) {
       frameloader->UpdatePositionAndSize(this);
     }
 
+    MaybeUpdateEmbedderColorScheme();
+
     if (!weakThis.IsAlive()) {
       return;
     }
     InvalidateFrame();
   }
 }
 
 nsIFrame* nsSubDocumentFrame::GetSubdocumentRootFrame() {
@@ -802,19 +804,59 @@ nsresult nsSubDocumentFrame::AttributeCh
     if (RefPtr<nsFrameLoader> frameloader = FrameLoader()) {
       frameloader->MarginsChanged();
     }
   }
 
   return NS_OK;
 }
 
+void nsSubDocumentFrame::MaybeUpdateEmbedderColorScheme() {
+  if (!mContent->IsXULElement()) {
+    // We only do this for XUL <browser>s.
+    return;
+  }
+
+  nsFrameLoader* fl = mFrameLoader.get();
+  if (!fl) {
+    return;
+  }
+
+  BrowsingContext* bc = fl->GetExtantBrowsingContext();
+  if (!bc) {
+    return;
+  }
+
+  auto usedColorScheme = LookAndFeel::ColorSchemeForFrame(this);
+  bool needUpdate = [&] {
+    switch (bc->GetEmbedderColorScheme()) {
+      case PrefersColorSchemeOverride::Light:
+        return usedColorScheme != ColorScheme::Light;
+      case PrefersColorSchemeOverride::Dark:
+        return usedColorScheme != ColorScheme::Dark;
+      case PrefersColorSchemeOverride::None:
+      case PrefersColorSchemeOverride::EndGuard_:
+        return true;
+    }
+  }();
+  if (!needUpdate) {
+    return;
+  }
+
+  auto value = usedColorScheme == ColorScheme::Dark
+                   ? PrefersColorSchemeOverride::Dark
+                   : PrefersColorSchemeOverride::Light;
+  Unused << bc->SetEmbedderColorScheme(value);
+}
+
 void nsSubDocumentFrame::DidSetComputedStyle(ComputedStyle* aOldComputedStyle) {
   nsAtomicContainerFrame::DidSetComputedStyle(aOldComputedStyle);
 
+  MaybeUpdateEmbedderColorScheme();
+
   // If this presshell has invisible ancestors, we don't need to propagate the
   // visibility style change to the subdocument since the subdocument should
   // have already set the IsUnderHiddenEmbedderElement flag in
   // nsSubDocumentFrame::Init.
   if (PresShell()->IsUnderHiddenEmbedderElement()) {
     return;
   }
 
--- a/layout/generic/nsSubDocumentFrame.h
+++ b/layout/generic/nsSubDocumentFrame.h
@@ -128,16 +128,17 @@ class nsSubDocumentFrame final : public 
   }
 
   nsFrameLoader* FrameLoader() const;
 
   enum class RetainPaintData : bool { No, Yes };
   void ResetFrameLoader(RetainPaintData);
   void ClearRetainedPaintData();
 
+  void MaybeUpdateEmbedderColorScheme();
   void PropagateIsUnderHiddenEmbedderElementToSubView(
       bool aIsUnderHiddenEmbedderElement);
 
   void ClearDisplayItems();
 
   void SubdocumentIntrinsicSizeOrRatioChanged();
 
   struct RemoteFramePaintData {
--- a/layout/style/test/test_non_content_accessible_env_vars.html
+++ b/layout/style/test/test_non_content_accessible_env_vars.html
@@ -4,16 +4,17 @@
 <div></div>
 <script>
 const NON_CONTENT_ACCESSIBLE_ENV_VARS = [
   "-moz-gtk-csd-titlebar-radius",
   "-moz-gtk-csd-menu-radius",
   "-moz-gtk-csd-minimize-button-position",
   "-moz-gtk-csd-maximize-button-position",
   "-moz-gtk-csd-close-button-position",
+  "-moz-content-preferred-color-scheme",
 ];
 
 const div = document.querySelector("div");
 for (const envVar of NON_CONTENT_ACCESSIBLE_ENV_VARS) {
   test(function() {
     div.style.setProperty("--foo", `env(${envVar},FALLBACK_VALUE)`);
 
     assert_equals(
--- a/servo/components/style/custom_properties.rs
+++ b/servo/components/style/custom_properties.rs
@@ -59,16 +59,30 @@ fn get_safearea_inset_bottom(device: &De
 fn get_safearea_inset_left(device: &Device) -> VariableValue {
     VariableValue::pixels(device.safe_area_insets().left)
 }
 
 fn get_safearea_inset_right(device: &Device) -> VariableValue {
     VariableValue::pixels(device.safe_area_insets().right)
 }
 
+fn get_content_preferred_color_scheme(device: &Device) -> VariableValue {
+    use crate::gecko::media_features::PrefersColorScheme;
+    let prefers_color_scheme = unsafe {
+        crate::gecko_bindings::bindings::Gecko_MediaFeatures_PrefersColorScheme(
+            device.document(),
+            /* use_content = */ true,
+        )
+    };
+    VariableValue::ident(match prefers_color_scheme {
+        PrefersColorScheme::Light => "light",
+        PrefersColorScheme::Dark => "dark",
+    })
+}
+
 static ENVIRONMENT_VARIABLES: [EnvironmentVariable; 4] = [
     make_variable!(atom!("safe-area-inset-top"), get_safearea_inset_top),
     make_variable!(atom!("safe-area-inset-bottom"), get_safearea_inset_bottom),
     make_variable!(atom!("safe-area-inset-left"), get_safearea_inset_left),
     make_variable!(atom!("safe-area-inset-right"), get_safearea_inset_right),
 ];
 
 macro_rules! lnf_int {
@@ -85,17 +99,17 @@ macro_rules! lnf_int_variable {
     ($atom:expr, $id:ident, $ctor:ident) => {{
         fn __eval(_: &Device) -> VariableValue {
             VariableValue::$ctor(lnf_int!($id))
         }
         make_variable!($atom, __eval)
     }};
 }
 
-static CHROME_ENVIRONMENT_VARIABLES: [EnvironmentVariable; 5] = [
+static CHROME_ENVIRONMENT_VARIABLES: [EnvironmentVariable; 6] = [
     lnf_int_variable!(
         atom!("-moz-gtk-csd-titlebar-radius"),
         TitlebarRadius,
         int_pixels
     ),
     lnf_int_variable!(atom!("-moz-gtk-csd-menu-radius"), GtkMenuRadius, int_pixels),
     lnf_int_variable!(
         atom!("-moz-gtk-csd-close-button-position"),
@@ -107,16 +121,17 @@ static CHROME_ENVIRONMENT_VARIABLES: [En
         GTKCSDMinimizeButtonPosition,
         integer
     ),
     lnf_int_variable!(
         atom!("-moz-gtk-csd-maximize-button-position"),
         GTKCSDMaximizeButtonPosition,
         integer
     ),
+    make_variable!(atom!("-moz-content-preferred-color-scheme"), get_content_preferred_color_scheme),
 ];
 
 impl CssEnvironment {
     #[inline]
     fn get(&self, name: &Atom, device: &Device) -> Option<VariableValue> {
         if let Some(var) = ENVIRONMENT_VARIABLES.iter().find(|var| var.name == *name) {
             return Some((var.evaluator)(device));
         }
@@ -314,16 +329,21 @@ impl VariableValue {
     fn integer(number: i32) -> Self {
         Self::from_token(Token::Number {
             has_sign: false,
             value: number as f32,
             int_value: Some(number),
         })
     }
 
+    /// Create VariableValue from an int.
+    fn ident(ident: &'static str) -> Self {
+        Self::from_token(Token::Ident(ident.into()))
+    }
+
     /// Create VariableValue from a float amount of CSS pixels.
     fn pixels(number: f32) -> Self {
         // FIXME (https://github.com/servo/rust-cssparser/issues/266):
         // No way to get TokenSerializationType::Dimension without creating
         // Token object.
         Self::from_token(Token::Dimension {
             has_sign: false,
             value: number,
--- a/toolkit/components/thumbnails/BackgroundPageThumbs.jsm
+++ b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm
@@ -375,16 +375,17 @@ const BackgroundPageThumbs = {
     Cc["@mozilla.org/gfx/screenmanager;1"]
       .getService(Ci.nsIScreenManager)
       .primaryScreen.GetRectDisplayPix({}, {}, swidth, sheight);
     let bwidth = Math.min(1024, swidth.value);
     // Setting the width and height attributes doesn't work -- the resulting
     // thumbnails are blank and transparent -- but setting the style does.
     browser.style.width = bwidth + "px";
     browser.style.height = (bwidth * sheight.value) / swidth.value + "px";
+    browser.style.colorScheme = "env(-moz-content-preferred-color-scheme)";
 
     this._parentWin.document.documentElement.appendChild(browser);
 
     browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW);
     browser.mute();
 
     // an event that is sent if the remote process crashes - no need to remove
     // it as we want it to be there as long as the browser itself lives.
--- a/xpcom/ds/StaticAtoms.py
+++ b/xpcom/ds/StaticAtoms.py
@@ -2241,16 +2241,17 @@ STATIC_ATOMS = [
     Atom("_moz_gtk_csd_minimize_button_position", "-moz-gtk-csd-minimize-button-position"),
     Atom("_moz_gtk_csd_maximize_button", "-moz-gtk-csd-maximize-button"),
     Atom("_moz_gtk_csd_maximize_button_position", "-moz-gtk-csd-maximize-button-position"),
     Atom("_moz_gtk_csd_close_button", "-moz-gtk-csd-close-button"),
     Atom("_moz_gtk_csd_close_button_position", "-moz-gtk-csd-close-button-position"),
     Atom("_moz_gtk_csd_reversed_placement", "-moz-gtk-csd-reversed-placement"),
     Atom("_moz_gtk_csd_menu_radius", "-moz-gtk-csd-menu-radius"),
     Atom("_moz_content_prefers_color_scheme", "-moz-content-prefers-color-scheme"),
+    Atom("_moz_content_preferred_color_scheme", "-moz-content-preferred-color-scheme"),
     Atom("_moz_proton_places_tooltip", "-moz-proton-places-tooltip"),
     Atom("_moz_system_dark_theme", "-moz-system-dark-theme"),
     # application commands
     Atom("Back", "Back"),
     Atom("Forward", "Forward"),
     Atom("Reload", "Reload"),
     Atom("Stop", "Stop"),
     Atom("Search", "Search"),