Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to detect #enable-force-dark flag on Chrome v78+ using JavaScript?

I've recently designed and implemented a dark mode for my website that uses custom dark colors to match the light (default) color scheme, and I also recently became aware that Chrome 78 has an optional flag called #enable-force-dark. When enabled (user must do so), Chrome automatically attempts to convert websites to a dark theme. It does so separate of the OS's preference, meaning that a user can have light mode system-wide but with this flag enabled Chrome will still convert.

I'm using the following code to detect whether or not a user's device prefers dark mode, as other threads have suggested. Note that I'm detecting it using javascript because there is a button that switches it back and forth, and it ultimately ended up a better solution than using the @media query.

if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    // my dark mode code goes here
}

This if statement is under a jQuery $(window).load function, and it works perfectly.

What I need is to be able to detect Chrome's new #enable-force-dark flag, reverse the changes Chrome makes, and enable mine instead, because the Chrome conversion is not complete and it screws up my custom styles. I'm aware that this feature is not widely used, but I'd like to future-proof.

Is this in any way possible? I don't need to ask the user to disable that flag, although if necessary I will. Thank you!

like image 596
Owen Sullivan Avatar asked Nov 06 '19 17:11

Owen Sullivan


1 Answers

[edit 2022: the answer below (by user axby) claims to give a now-officially-supported way to do this, that still relies on a few 'tricks'. Read that one first; this answer will remain as supplement to explain some of why those tricks work and what this mode is doing. It also contains a very limited bypass which should not be used, as it is finicky and only works in some modes.

If you're coming to this answer wondering "how can I detect force-dark so I can apply a light color stylesheet" that won't work. In particular: note that even if you can detect force-dark, you will not have access to certain colors in the palette for use as text or background-color, because the force-dark mapping (which I visually illustrate below) selectively inverts and remaps a subset of colors, and is an injective function which applies after any stylesheet. This is not particularly bad to work around by adjusting the palette, but there may be some undiscovered tricks involving images, canvas, blobs, SVG, etc."]


This is a non-answer, but Chrome's #enable-force-dark (in default mode, no sub-options) seems to perform a simple color mapping. Click on the code snippet below to dynamically generate this mapping for your viewing pleasure. (Be sure to scroll down the embedded frame for the interesting part.)

body {
  background-color:white;
}
activate about:flags #enable-force-dark before using this snippet<br/>

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script>

//document.domain = 'https://stacksnippets.net';

function gray(s) {
  return s/(S-1)*100 + '%';
}

var S = 32; // renders a table of size S*S cells
for(let BODY_BGCOLOR of ['white']) {//, 'black']) {
  document.body.innerHTML += `(body background-color = ${BODY_BGCOLOR})<br/>`;

  for(let CASE of ['bg','text','bg gray','text gray','bg gray + text gray']) {

/*
    var iframe = $(`<iframe src="${location.href.replace(/(:\/\/[^\/]*\/).*$/, '$1')}"></iframe>`).appendTo(document.body);
    var d = iframe[0].contentWindow.document;
    d.open(); d.close();
    $('body', d).append('test');
 */
    $('<table>').css({display:'inline-block', border:'5px solid blue', backgroundColor:'black'}).append(
      $(`<caption>${CASE}</caption>`)
    ).append(

      Array(S).fill().map((_,l)=> 
        $('<tr>').append(

          Array(S).fill().map((_,s)=> {


            gradient = `hsl(0,${s/(S-1)*100}%,${l/(S-1)*100}%)`;

            if (CASE=='bg') {
              var backgroundColor = gradient; 
              var color = gradient;
              var text = '_';

            } else if (CASE=='text') {
              var backgroundColor = '#444'; // you can change this to convince yourself color and background-color are 
                                            // independent and do not depend on each other
              var color = gradient;
              var text = 'X';
            } else if (CASE=='bg gray') {
              var backgroundColor = `hsl(0,0%,${gray(l)})`;
              var color = '';
              var text = '_';
            } else if (CASE=='text gray') {
              var backgroundColor = 'black';
              var color = `hsl(0,0%,${gray(s)})`;
              var text = 'X';
            } else if (CASE=='bg gray + text gray') {
              var backgroundColor = `hsl(0,0%,${gray(l)})`;
              var color = `hsl(0,0%,${gray(s)})`;
              var text = '▙';
            }


            return $('<td>').css({backgroundColor, color, fontSize:'0.5em'}).text(text);
          })
        )
      )
    ).appendTo(document.body)


  }
  $('<br/>').appendTo(document.body);
}
</script>

you can also right-click body tag and dynamically change its background-color, then highlight and unhighlight text, to convince yourself that color and background-color are usually independent

As of Chrome 79, it seems that anything the following occur independently:

  • anything with background-color > #ccc (so roughly #cdcdcd or higher) will have its background-color lightness-inverted (inverted while respecting hue)
  • anything with color < #999, roughly, will have its color lightness-inverted (inverted while respecting hue)

Unfortunately, it seems that 'force' really does mean force. You can visit https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme and see the 'Results' panel and right-click to view the results in a DOM inspector, and fiddle around and change the colors dynamically (even with a mouse by clicking the colors); it seems that what you would expect to happen (Chrome notices prefers-color-scheme: dark, and if so, does NOT invert perform forced color mapping), does NOT happen; it really is force.

You can't even detect this easily, to my knowledge (maybe someone can chime in here), since window.getComputedStyle(some_element) will (presumably correctly) return the CSS-spec computed style, and not the actual style displayed to the user.

Mathematically, it is clear that unless this option changes (you can submit a feature request), we cannot 'undo' this operation to allow us to then use our own stylesheet, or otherwise cleanly work around it. Here's a proof: if you look at the color palette generated above, take a step back and you will noticed it is reduced, so some colors you no longer have access to. Specifically, text will always appear light-ish (no dark text), and backgrounds will always appear dark-ish (no light backgrounds). Try coming up with a way to make dark text or light backgrounds in this mode; it appears at first glance to be impossible. Therefore, there is no hope of using traditional background-color and color without "weird stuff" happening. (You can't even use SVG text; SVG text properly has force-dark color-mapping applied to it too!)

But, not entirely impossible! I can think of three ideas:

  • If you really need it, I'm guessing you could use a <canvas> element, which doesn't seem to be affected... but that's really ugly (can't select text, etc.). Almost certainly don't do this.

  • un-forcing background-color:

  • background-color:gradient(...) seems to not be affected! You can therefore force any background color you want by using gradients whenever you really really want a light background (anything roughly #ccc or higher). This may be inefficient and cause extra GPU work (or it may not; you'd need to test it).

  • you could try a background-image:url(data:...) with a data-url; haven't tried it

  • un-forcing text-color:

  • this one is a lot tricker, but I just came up with a horrible kludge: find a series of CSS filters that acts like a constant function... or (due to the fact that color values are clipped), just rams the color values against the clip rails, then uses that as a constant, then applies any necessary transformations in function-space. It's like someone gives you a Photoshop/GIMP/etc. software and tells you "here is a pretty landscape image... using nothing but filters, make the entire image pure #0000ff blue! (or some chosen color)".... so.... let's do that

The general idea here is as follows:

.blue-text {

  filter: 
   brightness(0%)      /* multiple all colors by 0 to get black; white may not work */
   contrast(50%)       /* get gray from black */
   sepia(100%)         /* get non-gray from gray */
   contrast(250%)      /* \   may not be necessary, but here we */
   brightness(500%)    /*  |   get fully satured from less-than- */
   saturate(500%)      /* /    -fully saturated                  */
   /*hue-rotate(???deg) we have pure red now, but if we didn't, we'd do this*/
   hue-rotate(240deg)  /* rotate from pure red to whatever you want */
   saturate(200%);     /* for some reason, hue-rotate is not keeping saturation? */
                       /* unsaturate your color here, adjust lightness, etc. */

}

So... rather than pasting that horror every single time we want to force a color... we have a few options. Realize you only have to use this if you wish to use a dark-color text (so you don't need to do this for light colors). There are two approaches I can think of. The first approach would be if you are just mocking up something very quickly, or you're a front-end designer or doing a theme; this is the "pure CSS" way. Using the below method, you can use filter:... wherever you'd otherwise use a dark text color:..., either in the CSS declaration, or even inline. Some presets are defined, but need not be used:

<style>

/* horrible workaround to Chrome #force-dark-mode DEMONSTRATION; DO NOT USE THIS AS IT WILL DESTROY THE WEB, and cause HORRIBLE INEFFICIENCIES.
   Doesn't even work in some modes.
   NO REALLY.
   DO NOT USE IN PRODUCTION. DO NOT USE ON PERSONAL WEBSITES.
   DON'T USE THIS.
   DEMO ONLY. Use javascript polyfill instead below. */

:root {
  --color: brightness(0%) contrast(50%) sepia(100%) contrast(250%) brightness(500%) saturate(500%);
  
  --light: contrast(50%) brightness(200%);
  --dark: contrast(50%) brightness(50%);
  
  --red:     hue-rotate(0deg);
  --orange:  hue-rotate(40deg);
  --yellow:  hue-rotate(60deg);
  --green:   hue-rotate(120deg);
  --cyan:    hue-rotate(200deg);
  --blue:    hue-rotate(240deg);
  --purple:  hue-rotate(300deg);
  --magenta: hue-rotate(315deg);
}

.dark-green {
  filter: var(--color) var(--dark) var(--green);
}
.custom-color-light-blue {
  filter: var(--color)  contrast(50%) brightness(200%)  hue-rotate(240deg);  /*can flip order to be (hue)(contrast+brightness), but you get a slightly different color*/
}

</style>
</head>

DO NOT I REPEAT DO NOT USE THIS METHOD. Just imagine what would happen if everyone did this.

<p class="dark-green">example 1: dark green</p>
<p class="custom-color-light-blue">example 2: custom color (light blue)</p>
<p class="x" style="filter: var(--color) var(--light) var(--blue);">example 3: inline style="..." light blue</p>

misc examples with var(--color) var(--light|dark) var(--red), either as a class or as an inline style:

<p class="x" style="filter: var(--color) var(--red);">examples: red</p>
<p class="x" style="filter: var(--color) var(--light) var(--red);">examples: light red</p>
<p class="x" style="filter: var(--color) var(--dark) var(--red);">examples: dark red</p>

<p class="x" style="filter: var(--color) var(--orange);">examples: orange</p>
<p class="x" style="filter: var(--color) var(--light) var(--orange);">examples: light orange</p>
<p class="x" style="filter: var(--color) var(--dark) var(--orange);">examples: dark orange</p>

<p class="x" style="filter: var(--color) var(--yellow);">examples: yellow</p>
<p class="x" style="filter: var(--color) var(--light) var(--yellow);">examples: light yellow</p>
<p class="x" style="filter: var(--color) var(--dark) var(--yellow);">examples: dark yellow</p>

<p class="x" style="filter: var(--color) var(--green);">examples: green</p>
<p class="x" style="filter: var(--color) var(--light) var(--green);">examples: light green</p>
<p class="x" style="filter: var(--color) var(--dark) var(--green);">examples: dark green</p>

<p class="x" style="filter: var(--color) var(--cyan);">examples: cyan</p>
<p class="x" style="filter: var(--color) var(--light) var(--cyan);">examples: light cyan</p>
<p class="x" style="filter: var(--color) var(--dark) var(--cyan);">examples: dark cyan</p>

<p class="x" style="filter: var(--color) var(--blue);">examples: blue</p>
<p class="x" style="filter: var(--color) var(--light) var(--blue);">examples: light blue</p>
<p class="x" style="filter: var(--color) var(--dark) var(--blue);">examples: dark blue</p>

<p class="x" style="filter: var(--color) var(--purple);">examples: purple</p>
<p class="x" style="filter: var(--color) var(--light) var(--purple);">examples: light purple</p>
<p class="x" style="filter: var(--color) var(--dark) var(--purple);">examples: dark purple</p>

<p class="x" style="filter: var(--color) var(--magenta);">examples: magenta</p>
<p class="x" style="filter: var(--color) var(--light) var(--magenta);">examples: light magenta</p>
<p class="x" style="filter: var(--color) var(--dark) var(--magenta);">examples: dark magenta</p>

</body>

The second method would be to write a javascript library, which would scan the entire DOM, calling getComputedStyle(HTMLElement), and calculate the appropriate filter automatically, and then inject it as a CSS style. This might be slow if Chrome is not optimized to handle individual CSS filters on hundreds of elements at once on the page.

Also, either of the two methods might be slow or not work if Chrome is unable to use hundreds of CSS filters at once on the page (maybe you need to recompile a GPU shader every time you use a filter!!).

So... let's write that library...


Let's write an #enable-force-dark library

So the major problem with the above, is that we want to be forward-compatible. Namely, we don't want to create buggy code that will break the web by inserting these weird CSS stylings that hammer the GPU and generally make things horrible. We want to be forward-compatible.

One way to do this would be, if someone were to file a feature request with the blink team, asking if the best way to detect this, look through the blink engine sourcecode (it's open source), or ask if there will be some navigator.prefersColorScheme in the future. Otherwise, it's impossible to be forward-compatible in a manner less ugly than doing something like (if it's 2025 and this code is still running, send a popup notification to update it... you'd never want to do that in practice, but this just illustrates the concern that if you have no way to toggle this off, you'd need to have a permanent third-party dependency who you trust to update things, something like <script src="http://hypothetical-trusted-third-party.com/always-up-to-date-forcedark-polyfill">, which is going to cost infinite amounts of money and be a security hole).

What are we to do?

I think I noticed a very odd subtlety in the way force-dark works. While above, we noticed that the inversion functions are independent of color and background-color (that is they factor, i.e. changing background-color will not affect color or vice-versa)... this is not ALWAYS the case.

A very curious thing happens if we have an <iframe> AND the background-color property of the BODY TAG of the iframe is strictly less than < #333333. In this case (as of the time of this writing), Chrome uses this to 'detect' if the page has an 'intent' of being 'dark-ish', and if so, turns OFF dark-mode forcing on text (but does NOT turn off dark-mode forcing on background-color). The pseudocode seems to be:

function internalSecretChromeFunction(...) {
  websiteIsDarkish = (document.body.style.backgroundColor < #333333);
  if (websiteIsDarkish)
    enableColorInversionForText();
  enableColorInversionForBackgroundColor();
}

This brings us full circle to SVG text, which I said earlier also gets treated as normal text. We can thus use a canvas elements ctx.drawImage function to copy the SVG, it seems to directly copy the image from memory rather than redrawing it (perhaps a security issue?), but we can use it to build an #enable-force-dark detector as follows! First notice the matrix of possible states:

  • not(#force-dark)
  • dark text is dark
  • light text is light
  • #force-dark && body.bgcolor<#333
  • dark text is dark
  • light text is light
  • #force-dark && body.bgcolor>=#333
  • dark text is inverted (dark->light)
  • light text is inverted (light->dark)

How to proceed? We can determine this asynchronously in a few milliseconds:

  • create an <iframe> with <body style="background-white;"> that holds an <svg> element and a <canvas> element
  • copy the SVG to the canvas with drawImage
  • query the pixel color to see if it's inverted or not; return this value outside of the iframe

However, this seems to rely on behavior that may be a bug (three bugs, in fact)... 1) background-color is not inverted if body.background-color is dark, 2) prefers-color-scheme: dark media is not exported, 3) SVG images are copied rather than regenerated from scratch

[edit incoming in a few... weeks hopefully... someone should really contact the blink team]


Of course this feature may change, so this answer may someday no longer be valid.

like image 117
ninjagecko Avatar answered Sep 22 '22 08:09

ninjagecko