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.
This commit is contained in:
Penar Musaraj 2020-08-18 13:02:13 -04:00 committed by GitHub
parent 16e7744ab5
commit 882b0aac19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 101 additions and 37 deletions

View File

@ -33,6 +33,15 @@ export default Component.extend({
} }
}, },
@observes("placeholder")
placeholderChanged() {
if (this._editor) {
this._editor.setOptions({
placeholder: this.placeholder
});
}
},
@observes("disabled") @observes("disabled")
disabledStateChanged() { disabledStateChanged() {
this.changeDisabledState(); this.changeDisabledState();
@ -72,7 +81,6 @@ export default Component.extend({
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
loadScript("/javascripts/ace/ace.js").then(() => { loadScript("/javascripts/ace/ace.js").then(() => {
window.ace.require(["ace/ace"], loadedAce => { window.ace.require(["ace/ace"], loadedAce => {
loadedAce.config.set("loadWorkerFromBlob", false); loadedAce.config.set("loadWorkerFromBlob", false);
@ -85,7 +93,7 @@ export default Component.extend({
editor.setTheme("ace/theme/chrome"); editor.setTheme("ace/theme/chrome");
editor.setShowPrintMargin(false); editor.setShowPrintMargin(false);
editor.setOptions({ fontSize: "14px" }); editor.setOptions({ fontSize: "14px", placeholder: this.placeholder });
editor.getSession().setMode("ace/mode/" + this.mode); editor.getSession().setMode("ace/mode/" + this.mode);
editor.on("change", () => { editor.on("change", () => {
this._skipContentChangeEvent = true; this._skipContentChangeEvent = true;

View File

@ -1,3 +1,4 @@
import I18n from "I18n";
import { next } from "@ember/runloop"; import { next } from "@ember/runloop";
import Component from "@ember/component"; import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
@ -30,9 +31,17 @@ export default Component.extend({
activeSectionMode(targetName, fieldName) { activeSectionMode(targetName, fieldName) {
if (["settings", "translations"].includes(targetName)) return "yaml"; if (["settings", "translations"].includes(targetName)) return "yaml";
if (["extra_scss"].includes(targetName)) return "scss"; if (["extra_scss"].includes(targetName)) return "scss";
if (["color_definitions"].includes(fieldName)) return "scss";
return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; 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") @discourseComputed("fieldName", "currentTargetName", "theme")
activeSection: { activeSection: {
get(fieldName, target, model) { get(fieldName, target, model) {

View File

@ -72,7 +72,7 @@ const Theme = RestModel.extend({
} }
return { return {
common: [...common, "embedded_scss"], common: [...common, "embedded_scss", "color_definitions"],
desktop: common, desktop: common,
mobile: common, mobile: common,
settings: ["yaml"], settings: ["yaml"],

View File

@ -87,4 +87,4 @@
<pre class="field-error">{{error}}</pre> <pre class="field-error">{{error}}</pre>
{{/if}} {{/if}}
{{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true"}} {{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true" placeholder=placeholder}}

View File

@ -447,6 +447,12 @@
bottom: 0; bottom: 0;
} }
.ace_placeholder {
font-family: inherit;
font-size: $font-up-1;
color: $primary-high;
}
.status-actions { .status-actions {
float: right; float: right;
margin-top: 7px; margin-top: 7px;

View File

@ -458,7 +458,7 @@ module ApplicationHelper
dark_scheme_id = user_dark_scheme_id || SiteSetting.default_dark_mode_color_scheme_id dark_scheme_id = user_dark_scheme_id || SiteSetting.default_dark_mode_color_scheme_id
if dark_scheme_id != -1 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 end
result.html_safe result.html_safe
end end

View File

@ -271,7 +271,7 @@ class ColorScheme < ActiveRecord::Base
def publish_discourse_stylesheet def publish_discourse_stylesheet
if self.id 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) theme_ids = Theme.where(color_scheme_id: self.id).pluck(:id)
if theme_ids.present? if theme_ids.present?

View File

@ -265,7 +265,7 @@ class ThemeField < ActiveRecord::Base
end end
def self.scss_fields def self.scss_fields
@scss_fields ||= %w(scss embedded_scss) @scss_fields ||= %w(scss embedded_scss color_definitions)
end end
def self.basic_targets def self.basic_targets
@ -424,6 +424,9 @@ class ThemeField < ActiveRecord::Base
ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/, ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/,
targets: :common, names: "embedded_scss", types: :scss, targets: :common, names: "embedded_scss", types: :scss,
canonical: -> (h) { "common/embedded.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$/, ThemeFileMatcher.new(regex: /^(?:scss|stylesheets)\/(?<name>.+)\.scss$/,
targets: :extra_scss, names: nil, types: :scss, targets: :extra_scss, names: nil, types: :scss,
canonical: -> (h) { "stylesheets/#{h[:name]}.scss" }), canonical: -> (h) { "stylesheets/#{h[:name]}.scss" }),

View File

@ -3994,6 +3994,10 @@ en:
embedded_scss: embedded_scss:
text: "Embedded CSS" text: "Embedded CSS"
title: "Enter custom CSS to deliver with embedded version of comments" 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: head_tag:
text: "</head>" text: "</head>"
title: "HTML that will be inserted before the </head> tag" title: "HTML that will be inserted before the </head> tag"

View File

@ -21,7 +21,7 @@ module Stylesheet
file = File.read path file = File.read path
if asset.to_s == Stylesheet::Manager::COLOR_SCHEME_STYLESHEET 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
end end

View File

@ -115,14 +115,20 @@ module Stylesheet
register_imports! register_imports!
def self.import_color_definitions def self.import_color_definitions(theme_id)
return "" unless DiscoursePluginRegistry.color_definition_stylesheets.length
contents = +"" contents = +""
DiscoursePluginRegistry.color_definition_stylesheets.each do |name, path| DiscoursePluginRegistry.color_definition_stylesheets.each do |name, path|
contents << "// Color definitions from #{name}\n\n" contents << "// Color definitions from #{name}\n\n"
contents << File.read(path.to_s) contents << File.read(path.to_s)
contents << "\n\n" contents << "\n\n"
end 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 contents
end end

View File

@ -23,6 +23,10 @@ class Stylesheet::Manager
cache.hash.keys.select { |k| k =~ /theme/ }.each { |k| cache.delete(k) } cache.hash.keys.select { |k| k =~ /theme/ }.each { |k| cache.delete(k) }
end 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) def self.clear_core_cache!(targets)
cache.hash.keys.select { |k| k =~ /#{targets.join('|')}/ }.each { |k| cache.delete(k) } cache.hash.keys.select { |k| k =~ /#{targets.join('|')}/ }.each { |k| cache.delete(k) }
end end
@ -94,13 +98,14 @@ class Stylesheet::Manager
end end
def self.color_scheme_stylesheet_details(color_scheme_id = nil, media, theme_id) def self.color_scheme_stylesheet_details(color_scheme_id = nil, media, theme_id)
theme_id = theme_id || SiteSetting.default_theme_id
color_scheme = begin color_scheme = begin
ColorScheme.find(color_scheme_id) ColorScheme.find(color_scheme_id)
rescue rescue
# don't load fallback when requesting dark color scheme # don't load fallback when requesting dark color scheme
return false if media != "all" return false if media != "all"
theme_id = theme_id || SiteSetting.default_theme_id
Theme.find_by_id(theme_id)&.color_scheme || ColorScheme.base Theme.find_by_id(theme_id)&.color_scheme || ColorScheme.base
end end
@ -108,13 +113,14 @@ class Stylesheet::Manager
target = COLOR_SCHEME_STYLESHEET.to_sym target = COLOR_SCHEME_STYLESHEET.to_sym
current_hostname = Discourse.current_hostname 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] stylesheets = cache[cache_key]
return stylesheets if stylesheets.present? return stylesheets if stylesheets.present?
stylesheet = { color_scheme_id: color_scheme&.id } 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) builder.compile unless File.exists?(builder.stylesheet_fullpath)
href = builder.stylesheet_path(current_hostname) href = builder.stylesheet_path(current_hostname)
@ -132,20 +138,16 @@ class Stylesheet::Manager
%[<link href="#{href}" media="#{media}" rel="stylesheet"/>].html_safe %[<link href="#{href}" media="#{media}" rel="stylesheet"/>].html_safe
end 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_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s
"#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}_#{Discourse.current_hostname}" theme_string = theme_id ? "_theme#{theme_id}" : ""
end "#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}#{theme_string}_#{Discourse.current_hostname}"
def self.color_scheme_cache_clear(color_scheme)
cache_key = color_scheme_cache_key(color_scheme)
cache[cache_key] = nil
end end
def self.precompile_css 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 << 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| [:desktop, :mobile, :desktop_rtl, :mobile_rtl, :desktop_theme, :mobile_theme, :admin].each do |target|
theme_id = id || SiteSetting.default_theme_id theme_id = id || SiteSetting.default_theme_id
next if target =~ THEME_REGEX && theme_id == -1 next if target =~ THEME_REGEX && theme_id == -1
@ -156,17 +158,13 @@ class Stylesheet::Manager
builder.compile(force: true) builder.compile(force: true)
cache[cache_key] = nil cache[cache_key] = nil
end end
end
cs_ids = Theme.where('user_selectable OR id = ?', SiteSetting.default_theme_id).pluck(:color_scheme_id) scheme = ColorScheme.find_by_id(color_scheme_id) || ColorScheme.base
ColorScheme.where(id: cs_ids).each do |cs| STDERR.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{name} (#{scheme.name})"
target = COLOR_SCHEME_STYLESHEET
STDERR.puts "precompile target: #{target} #{cs.name}"
builder = self.new(target, nil, cs) builder = self.new(COLOR_SCHEME_STYLESHEET, id, scheme)
builder.compile(force: true) builder.compile(force: true)
cache_key = color_scheme_cache_key(cs) clear_color_scheme_cache!
cache[cache_key] = nil
end end
nil nil
@ -349,8 +347,6 @@ class Stylesheet::Manager
end end
def theme_digest def theme_digest
scss = ""
if [:mobile_theme, :desktop_theme].include?(@target) if [:mobile_theme, :desktop_theme].include?(@target)
scss_digest = theme.resolve_baked_field(:common, :scss) scss_digest = theme.resolve_baked_field(:common, :scss)
scss_digest += theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :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 category_updated = Category.where("uploaded_background_id IS NOT NULL").pluck(:updated_at).map(&:to_i).sum
if cs || category_updated > 0 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 else
digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}" digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}"

View File

@ -175,6 +175,21 @@ describe Stylesheet::Manager do
expect(digest1).to_not eq(digest2) expect(digest1).to_not eq(digest2)
end 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 end
describe 'color_scheme_stylesheets' do describe 'color_scheme_stylesheets' do
@ -248,6 +263,16 @@ describe Stylesheet::Manager do
expect(stylesheet2).to include("--primary: #c00;") expect(stylesheet2).to include("--primary: #c00;")
end 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 end
# this test takes too long, we don't run it by default # this test takes too long, we don't run it by default
@ -286,7 +311,7 @@ describe Stylesheet::Manager do
Stylesheet::Manager.precompile_css Stylesheet::Manager.precompile_css
results = StylesheetCache.pluck(:target) 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| core_targets.each do |tar|
expect(results.count { |target| target =~ /^#{tar}_(#{scheme1.id}|#{scheme2.id})$/ }).to eq(2) expect(results.count { |target| target =~ /^#{tar}_(#{scheme1.id}|#{scheme2.id})$/ }).to eq(2)
end end
@ -301,7 +326,7 @@ describe Stylesheet::Manager do
Stylesheet::Manager.precompile_css Stylesheet::Manager.precompile_css
results = StylesheetCache.pluck(:target) 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| core_targets.each do |tar|
expect(results.count { |target| target =~ /^(#{tar}_(#{scheme1.id}|#{scheme2.id})|#{tar})$/ }).to eq(3) expect(results.count { |target| target =~ /^(#{tar}_(#{scheme1.id}|#{scheme2.id})|#{tar})$/ }).to eq(3)

View File

@ -21,12 +21,15 @@ describe ColorScheme do
theme.save! theme.save!
href = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href] 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' }]) ColorSchemeRevisor.revise(scheme, colors: [{ name: 'primary', hex: 'bbb' }])
href2 = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href] 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(href).not_to eq(href2)
expect(colors_href).not_to eq(colors_href2)
end end
describe "new" do describe "new" do

View File

@ -60,6 +60,7 @@ describe RemoteTheme do
"common/header.html" => "I AM HEADER", "common/header.html" => "I AM HEADER",
"common/random.html" => "I AM SILLY", "common/random.html" => "I AM SILLY",
"common/embedded.scss" => "EMBED", "common/embedded.scss" => "EMBED",
"common/color_definitions.scss" => ":root{--color-var: red}",
"assets/font.woff2" => "FAKE FONT", "assets/font.woff2" => "FAKE FONT",
"settings.yaml" => "boolean_setting: true", "settings.yaml" => "boolean_setting: true",
"locales/en.yml" => "sometranslations" "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_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] 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["0-header"]).to eq("I AM HEADER")
expect(mapped["1-scss"]).to eq(scss_data) expect(mapped["1-scss"]).to eq(scss_data)
expect(mapped["0-embedded_scss"]).to eq("EMBED") 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("") expect(mapped["0-font"]).to eq("")
@ -103,7 +106,7 @@ describe RemoteTheme do
expect(mapped["4-en"]).to eq("sometranslations") 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.length).to eq(1)
expect(@theme.settings.first.value).to eq(true) expect(@theme.settings.first.value).to eq(true)