Implement dark mode without using CSS variables.
authorNick Bowler <nbowler@draconx.ca>
Wed, 11 May 2022 02:04:58 +0000 (22:04 -0400)
committerNick Bowler <nbowler@draconx.ca>
Wed, 11 May 2022 02:10:37 +0000 (22:10 -0400)
Instead of using CSS variables, we can just directly change colours
with appropriate media queries.

Moreover, implement a small postprocessor which consolidates all the
dark mode rules together at the end of the stylesheet, which makes
it a svery imple matter to also link an alternate stylesheet which
is selectable in older browsers.

Rules
content/style.scss
layouts/default.xsl
lib/colourmap.scss
lib/compiledcontent.rb [new file with mode: 0644]
lib/css-darkmode.rb [new file with mode: 0644]

diff --git a/Rules b/Rules
index b8538d86ea359e6e5246f6b01bc131c8b02eed29..1aa5907bcd4185432355a52907218eea35f4b4d2 100644 (file)
--- a/Rules
+++ b/Rules
@@ -217,9 +217,17 @@ compile '/**/*.scss' do
     filter :css_clean_selectors, \
         preserve_comments: true, \
         preserve_hacks: true
+    snapshot :before_darkmode
+    filter :css_darkmode
     write @item.identifier.without_ext + '.css'
 end
 
+compile '/style.scss', rep: :dark do
+    filter :compiled_content, snapshot: :before_darkmode
+    filter :css_darkmode, alternate: true
+    write "/dark.css"
+end
+
 compile '/gpg/*' do
     filter :wkd_export_armor
     write "/pubring/#{@item.identifier.components.last}.asc"
index 5f2ef9686cff45de2ac8899ff196ddd7aa5ee193..fb33f88d96ac3cd24cd89fcc7dc9d53d3af51bec 100644 (file)
@@ -329,7 +329,7 @@ $clickynames: name, date, size;
         @include usecolours($border-color: foreground);
         @at-root { @supports (outline-style: auto) { & {
             @include usecolour(outline, focusring, auto);
-            border-color: transparent;
+            border-color: transparent !important;
         }}}
     }
 }
@@ -455,14 +455,14 @@ ul.ordered > {
 }
 
 // page-specific dark mode styles
-@media (min-width: 35em) {
+@media (prefers-color-scheme: dark) and (min-width: 35em) {
     #page_weblog_responsive_tables {
         @each $tN in t6 t7 t8 {
             ##{$tN}>tbody>tr.#{$tN}-split {
-                @include usecolour_var_(border-bottom, ruledefault);
+                @include usecolour_dark_(border-bottom, ruledefault);
 
                 &:nth-of-type(odd) ~ tr:nth-of-type(odd) {
-                    @include usecolour_var_(background-color, tableshade);
+                    @include usecolour_dark_(background-color, tableshade);
                 }
             }
         }
index 837a792e4c7f734b8b8f591a77b87955a8a8519f..d5be1039e5061cf8c8dc2eff408eeb306c35de0a 100644 (file)
@@ -2,7 +2,7 @@
 <!--
   Nick's web site: XHTML output stage
 
-  Copyright © 2018-2021 Nick Bowler
+  Copyright © 2018-2022 Nick Bowler
 
   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
     <head>
       <meta name='viewport' content='width=device-width, initial-scale=1' />
       <link rel='stylesheet' type='text/css' href='/style.css' />
+      <link rel='alternate stylesheet' type='text/css' href='/dark.css'
+        title='Dark Style' />
       <link rel="icon" href="data:," />
       <title>
         <xsl:variable name='page-title' select='string(/document/title)' />
index 62468d93a4917ccdef06b231cdf0387d29f46c91..e4d8d65299596c18a40bf12a337f4316821e9ee0 100644 (file)
@@ -38,43 +38,30 @@ $_colourpropmap:
 //
 //   @include defcolours($bg: white black, $fg: black white);
 @mixin defcolours($args...) {
-    :root {
-        @each $colour, $list in keywords($args) {
-            $colourmap: map-merge($colourmap, ($colour: $list)) !global;
-            @if length($list) > 1 {
-                #{--colour- + $colour}: nth($list, 1);
-            }
-        }
-    }
-    @media (prefers-color-scheme: dark) {
-        :root {
-            @each $colour, $list in keywords($args) {
-                @if length($list) > 1 {
-                    #{--colour- + $colour}: nth($list, 2);
-                }
-            }
-        }
+    @each $colour, $list in keywords($args) {
+        $colourmap: map-merge($colourmap, ($colour: $list)) !global;
     }
 }
 
-// For the given previously-defined colour name, returns its value from
-// primary (light) colour scheme.
+// Obtain the colour value for a previously-defined colour name.  By
+// default, its primary (light) colour value is returned; this can be
+// changed with the $num keyword parameter.
 //
 // The $pre and $post keyword arguments may be used to supplement the
 // result with additional tokens either before or after the colour
 // value, respectively, as might be used for combined properties
 // such as border, outline, etc.
-@function getcolour($colour, $pre: (), $post: ()) {
-    @return join(append($pre, nth(map-get($colourmap, $colour), 1)), $post);
+@function getcolour($colour, $pre: (), $post: (), $num: 1) {
+    @return join(append($pre, nth(map-get($colourmap, $colour), $num)), $post);
 }
 
-@mixin usecolour_var_($prop, $colour, $pre: (), $post: ()) {
+@mixin usecolour_dark_($prop, $colour, $pre: (), $post: ()) {
     @if (length(map-get($colourmap, $colour)) > 1) {
         $transprop: map-get($_colourpropmap, $prop);
         @if $transprop {
-            #{$transprop}: var(--colour- + $colour)
+            #{$transprop}: getcolour($colour, $num: 2)
         } @else {
-            #{$prop}: join(append($pre, var(--colour- + $colour)), $post);
+            #{$prop}: getcolour($colour, $pre, $post, 2);
         }
     }
 }
@@ -93,7 +80,11 @@ $_colourpropmap:
 //   @include usecolour(border, fg, $pre: solid 1px);
 @mixin usecolour($prop, $colour, $pre: (), $post: ()) {
     #{$prop}: getcolour($colour, $pre, $post);
-    @at-root & { @include usecolour_var_($prop, $colour, $pre, $post); }
+    @at-root {
+        @media (prefers-color-scheme: dark) {
+            & { @include usecolour_dark_($prop, $colour, $pre, $post); }
+        }
+    }
 }
 
 // Convenience helper to assign multiple colour properties at once, for
@@ -110,9 +101,13 @@ $_colourpropmap:
     @each $prop, $colour in keywords($args) {
         #{$prop}: getcolour($colour);
     }
-    @at-root & {
-        @each $prop, $colour in keywords($args) {
-            @include usecolour_var_($prop, $colour);
+    @at-root {
+        @media (prefers-color-scheme: dark) {
+            & {
+                @each $prop, $colour in keywords($args) {
+                    @include usecolour_dark_($prop, $colour);
+                }
+            }
         }
     }
 }
diff --git a/lib/compiledcontent.rb b/lib/compiledcontent.rb
new file mode 100644 (file)
index 0000000..016ed23
--- /dev/null
@@ -0,0 +1,25 @@
+# Nick's web site: compiled_content filter.  Simply calls the compiled_content
+# method on the current item to retrieve the text from a named rep or snapshot.
+#
+# Copyright © 2022 Nick Bowler
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+class CompiledContentFilter < Nanoc::Filter
+    identifier :compiled_content
+
+    def run(content, params = {})
+        return @item.compiled_content(params)
+    end
+end
diff --git a/lib/css-darkmode.rb b/lib/css-darkmode.rb
new file mode 100644 (file)
index 0000000..50662e3
--- /dev/null
@@ -0,0 +1,158 @@
+# Nick's web site: css_darkmode filter.  Cut out dark-mode media queries
+# from a stylesheet and either re-insert them at the end of the same
+# stylesheet, or produce a separate stylesheet for use as an alternate.
+#
+# Copyright © 2022 Nick Bowler
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+class CssDarkModeFilter < Nanoc::Filter
+    identifier :css_darkmode
+
+    require 'crass'
+
+    def filter_whitespace(nodes)
+        return nodes.reject {|x| x[:node] == :whitespace }
+    end
+
+    def is_media_dark_block(x)
+        return false unless x[:node] == :simple_block
+
+        list = filter_whitespace(x[:value])
+        list.length == 3 and
+            list[0][:node] == :ident and
+            list[0][:value] == "prefers-color-scheme" and
+            list[1][:node] == :colon and
+            list[2][:node] == :ident and
+            list[2][:value] == "dark"
+    end
+
+    def is_media_dark(x)
+        return false unless x[:node] == :at_rule and x[:name] == "media"
+
+        x[:prelude].index {|y| is_media_dark_block(y)}
+    end
+
+    # Remove (prefers-color-scheme: dark) conditions from a media query.
+    # If the resulting query is empty, returns the query's block alone.
+    # Otherwise, returns the modified query.
+    def prune_media_dark(x)
+        start_index = 0
+        end_index = 0
+        state = :init
+
+        x[:prelude].each do |y|
+            case state
+            when :init
+                end_index += 1
+                if is_media_dark_block(y)
+                    state = :post
+                elsif y[:node] == :ident and y[:value] == "and"
+                    state = :pre
+                else
+                    start_index = end_index
+                    state = :init
+                end
+            when :pre
+                end_index += 1
+                if is_media_dark_block(y)
+                    state = :post
+                elsif y[:node] == :whitespace
+                    state = :pre
+                else
+                    start_index = end_index
+                    state = :init
+                end
+            when :post
+                if y[:node] == :whitespace
+                    end_index += 1
+                    state = :post
+                elsif y[:node] == :ident and y[:value] == "and"
+                    end_index += 1
+                    state = :post
+                else
+                    break
+                end
+            end
+        end
+
+        x[:prelude].slice!(start_index..end_index-1)
+        return x[:block] if filter_whitespace(x[:prelude]).empty?
+        x
+    end
+
+    def equiv_query(a, b)
+        return false unless a.class == b.class
+
+        case a
+        when Array
+            a = filter_whitespace(a)
+            b = filter_whitespace(b)
+
+            return false unless a.length == b.length
+            return a.map.with_index{|x,i| equiv_query(x, b[i])}.all?
+        when Hash
+            return false unless a[:node] == b[:node]
+            case a[:node]
+            when :at_rule
+                return equiv_query(a[:prelude], b[:prelude])
+            when :simple_block
+                return equiv_query(a[:value], b[:value])
+            else
+                return (a[:value] == b[:value] and a[:unit] == b[:unit])
+            end
+        end
+    end
+
+    def run(content, params = {})
+        params = {
+            preserve_comments: true,
+            preserve_hacks: true
+            }.merge(params)
+
+        tree = Crass.parse(content, params)
+
+        darknodes = []
+        tree.delete_if { |x| darknodes << x if is_media_dark(x) }
+
+        # Combine consecutive equivalent media queries into a single query
+        darknodes = darknodes.slice_when{|a,b| !equiv_query(a, b)}.each.map \
+        do |x|
+            combined = x[0].merge({block: x.map{|x| x[:block]}.flatten})
+        end
+
+        # In alternate mode, remove prefers-color-scheme queries.
+        if params[:alternate]
+            darknodes.map!{|x| prune_media_dark(x)}
+        end
+
+        darkcss = ""
+        darknodes.each do |x|
+            darkcss += "#{Crass::Parser.stringify(x).rstrip}\n"
+        end
+        darkcss.sub!(/^\n*/, "")
+
+        if params[:alternate]
+            prologue = tree.take_while do |x|
+                x[:node] == :comment or x[:node] == :whitespace
+            end
+
+            "#{Crass::Parser.stringify(prologue).rstrip}\n\n#{darkcss}"
+        else
+            output = "#{Crass::Parser.stringify(tree).rstrip}\n"
+            output += "\n#{darkcss}" unless darkcss.empty?
+            output
+        end
+    end
+end