From 882b0aac19cf604e0a89b64d00df7ec116e5f4ea Mon Sep 17 00:00:00 2001
From: Penar Musaraj <pmusaraj@gmail.com>
Date: Tue, 18 Aug 2020 13:02:13 -0400
Subject: [PATCH] DEV: Let themes extend color definitions (#10429)

Themes can now declare custom colors that get compiled in core's color definitions stylesheet, thus allowing themes to better support dark/light color schemes.

For example, if you need your theme to use tertiary for an element in a light color scheme and quaternary in a dark scheme, you can add the following SCSS to your theme's `color_definitions.scss` file:

```
:root {
  --mytheme-tertiary-or-quaternary: #{dark-light-choose($tertiary, $quaternary)};
}
```

And then use the `--mytheme-tertiary-or-quaternary` variable as the color property of that element. You can also use this file to add color variables that use SCSS color transformation functions (lighten, darken, saturate, etc.) without compromising your theme's compatibility with different color schemes.
---
 .../admin/components/ace-editor.js            | 12 +++++-
 .../admin/components/admin-theme-editor.js    |  9 ++++
 app/assets/javascripts/admin/models/theme.js  |  2 +-
 .../components/admin-theme-editor.hbs         |  2 +-
 .../stylesheets/common/admin/customize.scss   |  6 +++
 app/helpers/application_helper.rb             |  2 +-
 app/models/color_scheme.rb                    |  2 +-
 app/models/theme_field.rb                     |  5 ++-
 config/locales/client.en.yml                  |  4 ++
 lib/stylesheet/compiler.rb                    |  2 +-
 lib/stylesheet/importer.rb                    | 10 ++++-
 lib/stylesheet/manager.rb                     | 43 +++++++++----------
 spec/components/stylesheet/manager_spec.rb    | 29 ++++++++++++-
 spec/models/color_scheme_spec.rb              |  3 ++
 spec/models/remote_theme_spec.rb              |  7 ++-
 15 files changed, 101 insertions(+), 37 deletions(-)

diff --git a/app/assets/javascripts/admin/components/ace-editor.js b/app/assets/javascripts/admin/components/ace-editor.js
index 407b0fed603..f4868546757 100644
--- a/app/assets/javascripts/admin/components/ace-editor.js
+++ b/app/assets/javascripts/admin/components/ace-editor.js
@@ -33,6 +33,15 @@ export default Component.extend({
     }
   },
 
+  @observes("placeholder")
+  placeholderChanged() {
+    if (this._editor) {
+      this._editor.setOptions({
+        placeholder: this.placeholder
+      });
+    }
+  },
+
   @observes("disabled")
   disabledStateChanged() {
     this.changeDisabledState();
@@ -72,7 +81,6 @@ export default Component.extend({
 
   didInsertElement() {
     this._super(...arguments);
-
     loadScript("/javascripts/ace/ace.js").then(() => {
       window.ace.require(["ace/ace"], loadedAce => {
         loadedAce.config.set("loadWorkerFromBlob", false);
@@ -85,7 +93,7 @@ export default Component.extend({
 
         editor.setTheme("ace/theme/chrome");
         editor.setShowPrintMargin(false);
-        editor.setOptions({ fontSize: "14px" });
+        editor.setOptions({ fontSize: "14px", placeholder: this.placeholder });
         editor.getSession().setMode("ace/mode/" + this.mode);
         editor.on("change", () => {
           this._skipContentChangeEvent = true;
diff --git a/app/assets/javascripts/admin/components/admin-theme-editor.js b/app/assets/javascripts/admin/components/admin-theme-editor.js
index 0299c047158..0ae1d1491b0 100644
--- a/app/assets/javascripts/admin/components/admin-theme-editor.js
+++ b/app/assets/javascripts/admin/components/admin-theme-editor.js
@@ -1,3 +1,4 @@
+import I18n from "I18n";
 import { next } from "@ember/runloop";
 import Component from "@ember/component";
 import discourseComputed from "discourse-common/utils/decorators";
@@ -30,9 +31,17 @@ export default Component.extend({
   activeSectionMode(targetName, fieldName) {
     if (["settings", "translations"].includes(targetName)) return "yaml";
     if (["extra_scss"].includes(targetName)) return "scss";
+    if (["color_definitions"].includes(fieldName)) return "scss";
     return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html";
   },
 
+  @discourseComputed("currentTargetName", "fieldName")
+  placeholder(targetName, fieldName) {
+    return fieldName && fieldName === "color_definitions"
+      ? I18n.t("admin.customize.theme.color_definitions.placeholder")
+      : "";
+  },
+
   @discourseComputed("fieldName", "currentTargetName", "theme")
   activeSection: {
     get(fieldName, target, model) {
diff --git a/app/assets/javascripts/admin/models/theme.js b/app/assets/javascripts/admin/models/theme.js
index eb2aecfd602..06d5faeefc2 100644
--- a/app/assets/javascripts/admin/models/theme.js
+++ b/app/assets/javascripts/admin/models/theme.js
@@ -72,7 +72,7 @@ const Theme = RestModel.extend({
     }
 
     return {
-      common: [...common, "embedded_scss"],
+      common: [...common, "embedded_scss", "color_definitions"],
       desktop: common,
       mobile: common,
       settings: ["yaml"],
diff --git a/app/assets/javascripts/admin/templates/components/admin-theme-editor.hbs b/app/assets/javascripts/admin/templates/components/admin-theme-editor.hbs
index 3ebd8f2c866..ce928108a21 100644
--- a/app/assets/javascripts/admin/templates/components/admin-theme-editor.hbs
+++ b/app/assets/javascripts/admin/templates/components/admin-theme-editor.hbs
@@ -87,4 +87,4 @@
   <pre class="field-error">{{error}}</pre>
 {{/if}}
 
-{{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true"}}
+{{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true" placeholder=placeholder}}
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
index a9a0e742954..970a72047e0 100644
--- a/app/assets/stylesheets/common/admin/customize.scss
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -447,6 +447,12 @@
       bottom: 0;
     }
 
+    .ace_placeholder {
+      font-family: inherit;
+      font-size: $font-up-1;
+      color: $primary-high;
+    }
+
     .status-actions {
       float: right;
       margin-top: 7px;
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index e8c260f7a5a..3d9523b063e 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -458,7 +458,7 @@ module ApplicationHelper
     dark_scheme_id =  user_dark_scheme_id || SiteSetting.default_dark_mode_color_scheme_id
 
     if dark_scheme_id != -1
-      result << Stylesheet::Manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)')
+      result << Stylesheet::Manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)', theme_ids)
     end
     result.html_safe
   end
diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb
index eb09f1cddbf..29788581781 100644
--- a/app/models/color_scheme.rb
+++ b/app/models/color_scheme.rb
@@ -271,7 +271,7 @@ class ColorScheme < ActiveRecord::Base
 
   def publish_discourse_stylesheet
     if self.id
-      Stylesheet::Manager.color_scheme_cache_clear(self)
+      Stylesheet::Manager.clear_color_scheme_cache!
 
       theme_ids = Theme.where(color_scheme_id: self.id).pluck(:id)
       if theme_ids.present?
diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb
index a15d42ec9e5..4e525b0c1db 100644
--- a/app/models/theme_field.rb
+++ b/app/models/theme_field.rb
@@ -265,7 +265,7 @@ class ThemeField < ActiveRecord::Base
   end
 
   def self.scss_fields
-    @scss_fields ||= %w(scss embedded_scss)
+    @scss_fields ||= %w(scss embedded_scss color_definitions)
   end
 
   def self.basic_targets
@@ -424,6 +424,9 @@ class ThemeField < ActiveRecord::Base
     ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/,
                          targets: :common, names: "embedded_scss", types: :scss,
                          canonical: -> (h) { "common/embedded.scss" }),
+    ThemeFileMatcher.new(regex: /^common\/color_definitions\.scss$/,
+                         targets: :common, names: "color_definitions", types: :scss,
+                         canonical: -> (h) { "common/color_definitions.scss" }),
     ThemeFileMatcher.new(regex: /^(?:scss|stylesheets)\/(?<name>.+)\.scss$/,
                          targets: :extra_scss, names: nil, types: :scss,
                          canonical: -> (h) { "stylesheets/#{h[:name]}.scss" }),
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 0168e342682..333cfd090d5 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3994,6 +3994,10 @@ en:
           embedded_scss:
             text: "Embedded CSS"
             title: "Enter custom CSS to deliver with embedded version of comments"
+          color_definitions:
+            text: "Color Definitions"
+            title: "Enter custom color definitions (advanced users only)"
+            placeholder: "\r\nUse this stylesheet to add custom colors to the list of CSS custom properties.\r\n\r\nExample: \r\n\r\n:root {\r\n  --mytheme-tertiary-or-quaternary: #{dark-light-choose($tertiary, $quaternary)};\r\n}\r\n\r\nPrefixing the property names is highly recommended to avoid conflicts with plugins and/or core."
           head_tag:
             text: "</head>"
             title: "HTML that will be inserted before the </head> tag"
diff --git a/lib/stylesheet/compiler.rb b/lib/stylesheet/compiler.rb
index b5575cda494..8596c5d8b86 100644
--- a/lib/stylesheet/compiler.rb
+++ b/lib/stylesheet/compiler.rb
@@ -21,7 +21,7 @@ module Stylesheet
         file = File.read path
 
         if asset.to_s == Stylesheet::Manager::COLOR_SCHEME_STYLESHEET
-          file += Stylesheet::Importer.import_color_definitions
+          file += Stylesheet::Importer.import_color_definitions(options[:theme_id])
         end
       end
 
diff --git a/lib/stylesheet/importer.rb b/lib/stylesheet/importer.rb
index f1901969ae7..e08d766ebf5 100644
--- a/lib/stylesheet/importer.rb
+++ b/lib/stylesheet/importer.rb
@@ -115,14 +115,20 @@ module Stylesheet
 
     register_imports!
 
-    def self.import_color_definitions
-      return "" unless DiscoursePluginRegistry.color_definition_stylesheets.length
+    def self.import_color_definitions(theme_id)
       contents = +""
       DiscoursePluginRegistry.color_definition_stylesheets.each do |name, path|
         contents << "// Color definitions from #{name}\n\n"
         contents << File.read(path.to_s)
         contents << "\n\n"
       end
+
+      if theme_id
+        Theme.list_baked_fields([theme_id], :common, :color_definitions).each do |row|
+          contents << "// Color definitions from #{Theme.find_by_id(theme_id)&.name}\n\n"
+          contents << row.value
+        end
+      end
       contents
     end
 
diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb
index 0101847578b..209d25ad52c 100644
--- a/lib/stylesheet/manager.rb
+++ b/lib/stylesheet/manager.rb
@@ -23,6 +23,10 @@ class Stylesheet::Manager
     cache.hash.keys.select { |k| k =~ /theme/ }.each { |k| cache.delete(k) }
   end
 
+  def self.clear_color_scheme_cache!
+    cache.hash.keys.select { |k| k =~ /color_definitions/ }.each { |k| cache.delete(k) }
+  end
+
   def self.clear_core_cache!(targets)
     cache.hash.keys.select { |k| k =~ /#{targets.join('|')}/ }.each { |k| cache.delete(k) }
   end
@@ -94,13 +98,14 @@ class Stylesheet::Manager
   end
 
   def self.color_scheme_stylesheet_details(color_scheme_id = nil, media, theme_id)
+    theme_id = theme_id || SiteSetting.default_theme_id
+
     color_scheme = begin
       ColorScheme.find(color_scheme_id)
     rescue
       # don't load fallback when requesting dark color scheme
       return false if media != "all"
 
-      theme_id = theme_id || SiteSetting.default_theme_id
       Theme.find_by_id(theme_id)&.color_scheme || ColorScheme.base
     end
 
@@ -108,13 +113,14 @@ class Stylesheet::Manager
 
     target = COLOR_SCHEME_STYLESHEET.to_sym
     current_hostname = Discourse.current_hostname
-    cache_key = color_scheme_cache_key(color_scheme)
+    cache_key = color_scheme_cache_key(color_scheme, theme_id)
     stylesheets = cache[cache_key]
     return stylesheets if stylesheets.present?
 
     stylesheet = { color_scheme_id: color_scheme&.id }
 
-    builder = self.new(target, nil, color_scheme)
+    builder = self.new(target, theme_id, color_scheme)
+
     builder.compile unless File.exists?(builder.stylesheet_fullpath)
 
     href = builder.stylesheet_path(current_hostname)
@@ -132,20 +138,16 @@ class Stylesheet::Manager
     %[<link href="#{href}" media="#{media}" rel="stylesheet"/>].html_safe
   end
 
-  def self.color_scheme_cache_key(color_scheme)
+  def self.color_scheme_cache_key(color_scheme, theme_id = nil)
     color_scheme_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s
-    "#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}_#{Discourse.current_hostname}"
-  end
-
-  def self.color_scheme_cache_clear(color_scheme)
-    cache_key = color_scheme_cache_key(color_scheme)
-    cache[cache_key] = nil
+    theme_string = theme_id ? "_theme#{theme_id}" : ""
+    "#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}#{theme_string}_#{Discourse.current_hostname}"
   end
 
   def self.precompile_css
-    themes = Theme.where('user_selectable OR id = ?', SiteSetting.default_theme_id).pluck(:id, :name)
+    themes = Theme.where('user_selectable OR id = ?', SiteSetting.default_theme_id).pluck(:id, :name, :color_scheme_id)
     themes << nil
-    themes.each do |id, name|
+    themes.each do |id, name, color_scheme_id|
       [:desktop, :mobile, :desktop_rtl, :mobile_rtl, :desktop_theme, :mobile_theme, :admin].each do |target|
         theme_id = id || SiteSetting.default_theme_id
         next if target =~ THEME_REGEX && theme_id == -1
@@ -156,17 +158,13 @@ class Stylesheet::Manager
         builder.compile(force: true)
         cache[cache_key] = nil
       end
-    end
 
-    cs_ids = Theme.where('user_selectable OR id = ?', SiteSetting.default_theme_id).pluck(:color_scheme_id)
-    ColorScheme.where(id: cs_ids).each do |cs|
-      target = COLOR_SCHEME_STYLESHEET
-      STDERR.puts "precompile target: #{target} #{cs.name}"
+      scheme = ColorScheme.find_by_id(color_scheme_id) || ColorScheme.base
+      STDERR.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{name} (#{scheme.name})"
 
-      builder = self.new(target, nil, cs)
+      builder = self.new(COLOR_SCHEME_STYLESHEET, id, scheme)
       builder.compile(force: true)
-      cache_key = color_scheme_cache_key(cs)
-      cache[cache_key] = nil
+      clear_color_scheme_cache!
     end
 
     nil
@@ -349,8 +347,6 @@ class Stylesheet::Manager
   end
 
   def theme_digest
-    scss = ""
-
     if [:mobile_theme, :desktop_theme].include?(@target)
       scss_digest = theme.resolve_baked_field(:common, :scss)
       scss_digest += theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss)
@@ -401,7 +397,8 @@ class Stylesheet::Manager
     category_updated = Category.where("uploaded_background_id IS NOT NULL").pluck(:updated_at).map(&:to_i).sum
 
     if cs || category_updated > 0
-      Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{Stylesheet::Manager.last_file_updated}-#{category_updated}"
+      theme_color_defs = theme&.resolve_baked_field(:common, :color_definitions)
+      Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.last_file_updated}-#{category_updated}"
     else
       digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}"
 
diff --git a/spec/components/stylesheet/manager_spec.rb b/spec/components/stylesheet/manager_spec.rb
index a544d9ee64e..7ca60a24349 100644
--- a/spec/components/stylesheet/manager_spec.rb
+++ b/spec/components/stylesheet/manager_spec.rb
@@ -175,6 +175,21 @@ describe Stylesheet::Manager do
 
       expect(digest1).to_not eq(digest2)
     end
+
+    it "updates digest when updating a theme's color definitions" do
+      scheme = ColorScheme.base
+      theme = Fabricate(:theme)
+      manager = Stylesheet::Manager.new(:color_definitions, theme.id, scheme)
+      digest1 = manager.color_scheme_digest
+
+      theme.set_field(target: :common, name: :color_definitions, value: 'body {color: brown}')
+      theme.save!
+
+      digest2 = manager.color_scheme_digest
+
+      expect(digest1).to_not eq(digest2)
+    end
+
   end
 
   describe 'color_scheme_stylesheets' do
@@ -248,6 +263,16 @@ describe Stylesheet::Manager do
       expect(stylesheet2).to include("--primary: #c00;")
     end
 
+    it "includes theme color definitions in color scheme" do
+      theme = Fabricate(:theme)
+      theme.set_field(target: :common, name: :color_definitions, value: ':root {--special: rebeccapurple;}')
+      theme.save!
+
+      scheme = ColorScheme.base
+      stylesheet = Stylesheet::Manager.new(:color_definitions, theme.id, scheme).compile
+
+      expect(stylesheet).to include("--special: rebeccapurple")
+    end
   end
 
   # this test takes too long, we don't run it by default
@@ -286,7 +311,7 @@ describe Stylesheet::Manager do
       Stylesheet::Manager.precompile_css
       results = StylesheetCache.pluck(:target)
 
-      expect(results.size).to eq(16) # (2 themes x 7 targets) + (2 themes x 1 color scheme)
+      expect(results.size).to eq(17) # (2 themes x 7 targets) + 3 color schemes (2 themes, 1 base)
       core_targets.each do |tar|
         expect(results.count { |target| target =~ /^#{tar}_(#{scheme1.id}|#{scheme2.id})$/ }).to eq(2)
       end
@@ -301,7 +326,7 @@ describe Stylesheet::Manager do
       Stylesheet::Manager.precompile_css
       results = StylesheetCache.pluck(:target)
 
-      expect(results.size).to eq(21) # (2 themes x 7 targets) + (1 no/default/core theme x 5 core targets) + (2 themes x 1 color scheme)
+      expect(results.size).to eq(22) # (2 themes x 7 targets) + (1 no/default/core theme x 5 core targets) + 3 color schemes (2 themes, 1 base)
 
       core_targets.each do |tar|
         expect(results.count { |target| target =~ /^(#{tar}_(#{scheme1.id}|#{scheme2.id})|#{tar})$/ }).to eq(3)
diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb
index 4a9f34018b4..fe2dd608feb 100644
--- a/spec/models/color_scheme_spec.rb
+++ b/spec/models/color_scheme_spec.rb
@@ -21,12 +21,15 @@ describe ColorScheme do
     theme.save!
 
     href = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href]
+    colors_href = Stylesheet::Manager.color_scheme_stylesheet_details(scheme.id, "all", nil)
 
     ColorSchemeRevisor.revise(scheme, colors: [{ name: 'primary', hex: 'bbb' }])
 
     href2 = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href]
+    colors_href2 = Stylesheet::Manager.color_scheme_stylesheet_details(scheme.id, "all", nil)
 
     expect(href).not_to eq(href2)
+    expect(colors_href).not_to eq(colors_href2)
   end
 
   describe "new" do
diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb
index 29cc4b939d5..8b0e44ea416 100644
--- a/spec/models/remote_theme_spec.rb
+++ b/spec/models/remote_theme_spec.rb
@@ -60,6 +60,7 @@ describe RemoteTheme do
         "common/header.html" => "I AM HEADER",
         "common/random.html" => "I AM SILLY",
         "common/embedded.scss" => "EMBED",
+        "common/color_definitions.scss" => ":root{--color-var: red}",
         "assets/font.woff2" => "FAKE FONT",
         "settings.yaml" => "boolean_setting: true",
         "locales/en.yml" => "sometranslations"
@@ -90,12 +91,14 @@ describe RemoteTheme do
 
       expect(@theme.theme_modifier_set.serialize_topic_excerpts).to eq(true)
 
-      expect(@theme.theme_fields.length).to eq(9)
+      expect(@theme.theme_fields.length).to eq(10)
 
       mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten]
+
       expect(mapped["0-header"]).to eq("I AM HEADER")
       expect(mapped["1-scss"]).to eq(scss_data)
       expect(mapped["0-embedded_scss"]).to eq("EMBED")
+      expect(mapped["0-color_definitions"]).to eq(":root{--color-var: red}")
 
       expect(mapped["0-font"]).to eq("")
 
@@ -103,7 +106,7 @@ describe RemoteTheme do
 
       expect(mapped["4-en"]).to eq("sometranslations")
 
-      expect(mapped.length).to eq(9)
+      expect(mapped.length).to eq(10)
 
       expect(@theme.settings.length).to eq(1)
       expect(@theme.settings.first.value).to eq(true)