keystone_ui-preferences 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +54 -27
- data/app/controllers/concerns/keystone_ui/preferences/component_preferences.rb +2 -2
- data/app/javascript/keystone_ui/preferences/appearance_editor_controller.js +235 -0
- data/app/models/keystone_ui/preferences/appearance_choice.rb +66 -1
- data/app/models/keystone_ui/preferences/checked_theme.rb +109 -0
- data/app/models/keystone_ui/preferences/chosen_colours.rb +7 -2
- data/app/models/keystone_ui/preferences/pick_account_appearance.rb +12 -2
- data/app/models/keystone_ui/preferences/pick_appearance.rb +13 -1
- data/app/models/keystone_ui/preferences/sent_theme.rb +49 -0
- data/app/models/keystone_ui/preferences/theme_css.rb +89 -0
- data/app/models/keystone_ui/preferences/theme_reader.rb +34 -0
- data/app/models/keystone_ui/preferences/theme_roles.rb +89 -0
- data/app/views/keystone_ui/preferences/settings/_account_appearance.html.erb +15 -3
- data/app/views/keystone_ui/preferences/settings/_appearance.html.erb +30 -18
- data/app/views/keystone_ui/preferences/settings/_colour_choices.html.erb +40 -8
- data/app/views/keystone_ui/preferences/settings/_editor_layout.html.erb +29 -0
- data/app/views/keystone_ui/preferences/settings/_theme_preview.html.erb +34 -0
- data/app/views/keystone_ui/preferences/settings/_theme_settings.html.erb +52 -0
- data/lib/generators/keystone_ui/preferences/install/install_generator.rb +4 -4
- data/lib/generators/keystone_ui/preferences/update/update_generator.rb +4 -4
- data/lib/keystone_ui/preferences/colors_check.rb +3 -2
- data/lib/keystone_ui/preferences/configuration.rb +2 -1
- data/lib/keystone_ui/preferences/version.rb +1 -1
- metadata +28 -7
- data/app/javascript/keystone_ui/preferences/theme_settings_controller.js +0 -59
- data/app/models/keystone_ui/preferences/palette_css.rb +0 -32
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class CheckedTheme
|
|
6
|
+
NUMBER = '-?(?:\d+(?:\.\d+)?|\.\d+)%?'
|
|
7
|
+
GAP = '(?:\s*,\s*|\s+)'
|
|
8
|
+
HEX = /\A#(?:\h{3}|\h{4}|\h{6}|\h{8})\z/
|
|
9
|
+
FUNCTION = /\A(?:rgba?|hsla?|oklch)\(\s*#{NUMBER}#{GAP}#{NUMBER}#{GAP}#{NUMBER}(?:\s*[\/,]\s*#{NUMBER})?\s*\)\z/
|
|
10
|
+
COLOUR = Regexp.union(HEX, FUNCTION)
|
|
11
|
+
UNSIGNED_LENGTH = '(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em))'
|
|
12
|
+
LENGTH = /\A#{UNSIGNED_LENGTH}\z/
|
|
13
|
+
SIGNED_LENGTH = /\A-?#{UNSIGNED_LENGTH}\z/
|
|
14
|
+
FONT_NAME = "[A-Za-z0-9-]+(?: [A-Za-z0-9-]+)*"
|
|
15
|
+
FONT = "(?:#{FONT_NAME}|\"#{FONT_NAME}\"|'#{FONT_NAME}')"
|
|
16
|
+
FONT_LIST = /\A#{FONT}(?:\s*,\s*#{FONT})*\z/
|
|
17
|
+
WEIGHT = /\A[1-9]00\z/
|
|
18
|
+
OPACITY = /\A(?:0(?:\.\d+)?|\.\d+|1(?:\.0+)?)\z/
|
|
19
|
+
PATTERNS = {
|
|
20
|
+
colour: COLOUR, shadow_colour: COLOUR, length: LENGTH, signed_length: SIGNED_LENGTH, opacity: OPACITY, weight: WEIGHT,
|
|
21
|
+
letter_spacing: Regexp.union(SIGNED_LENGTH, /\Anormal\z/), font_list: FONT_LIST
|
|
22
|
+
}.freeze
|
|
23
|
+
REASONS = {
|
|
24
|
+
shadow_colour: "is not hex, rgb(), hsl() or oklch() written with plain numbers",
|
|
25
|
+
length: "is not a number followed by px, rem or em, or zero",
|
|
26
|
+
signed_length: "is not a number followed by px, rem or em, or zero",
|
|
27
|
+
opacity: "is not a number from 0 to 1",
|
|
28
|
+
weight: "is not a font weight from 100 to 900 in hundreds",
|
|
29
|
+
letter_spacing: "is not normal or a number followed by px, rem or em, or zero",
|
|
30
|
+
font_list: "is not a list of font names made of letters, digits, spaces and hyphens"
|
|
31
|
+
}.freeze
|
|
32
|
+
SIDES = %w[light dark].freeze
|
|
33
|
+
SYSTEM_FONTS = [
|
|
34
|
+
"system-ui", "ui-sans-serif", "ui-serif", "ui-monospace", "ui-rounded", "sans-serif", "serif", "monospace", "cursive",
|
|
35
|
+
"-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica", "Helvetica Neue", "Arial", "Georgia",
|
|
36
|
+
"Times New Roman", "Menlo", "Monaco", "Consolas", "Courier New", "SFMono-Regular"
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(values)
|
|
40
|
+
@values = values
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def refusal
|
|
44
|
+
name, side, value = failure
|
|
45
|
+
return unless name
|
|
46
|
+
return Refusal.new("The --#{name} value names no font this app offers, so it was not saved.") if matches?(name, value)
|
|
47
|
+
|
|
48
|
+
reason(kind_of(name), name, side)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def failing_setting
|
|
52
|
+
failure&.first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def value
|
|
56
|
+
{ "theme" => kept.to_h { |name, sides| [ name, kind_of(name) == :font_list ? sides.transform_values { |list| offered_fonts(list).map { |font| written_font(font) }.join(", ") } : sides ] } }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def failure
|
|
62
|
+
kept.each do |name, sides|
|
|
63
|
+
sides.each do |side, value|
|
|
64
|
+
return [ name, side, value ] unless matches?(name, value) && names_offered_font?(name, value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def matches?(name, value)
|
|
71
|
+
value.to_s.match?(PATTERNS.fetch(kind_of(name)))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def names_offered_font?(name, value)
|
|
75
|
+
kind_of(name) != :font_list || offered_fonts(value).any?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sides_of(name)
|
|
79
|
+
kind_of(name) == :colour ? SIDES : SIDES.first(1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def kind_of(name)
|
|
83
|
+
ThemeRoles::ROLES.fetch(name)[:kind]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def offered_fonts(list)
|
|
87
|
+
offered = (KeystoneUi::Preferences.configuration.theme_fonts + SYSTEM_FONTS).map(&:downcase)
|
|
88
|
+
list.split(",").map { |font| font.strip.delete("\"'") }.select { |font| offered.include?(font.downcase) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def written_font(font)
|
|
92
|
+
font.include?(" ") ? "\"#{font}\"" : font
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reason(kind, name, side)
|
|
96
|
+
return Refusal.new("The #{side} --#{name} colour is not hex, rgb(), hsl() or oklch() written with plain numbers, so it was not saved.") if kind == :colour
|
|
97
|
+
|
|
98
|
+
Refusal.new("The --#{name} value #{REASONS.fetch(kind)}, so it was not saved.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def kept
|
|
102
|
+
@kept ||= ThemeRoles.names.each_with_object({}) do |name, kept|
|
|
103
|
+
sides = @values.to_h[name].to_h.slice(*sides_of(name))
|
|
104
|
+
kept[name] = sides if sides.any?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -8,12 +8,13 @@ module KeystoneUi
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def refusal
|
|
11
|
-
return Refusal.new("Choose Default, one of the colour templates or Custom.") unless template_offered?
|
|
11
|
+
return Refusal.new("Choose Default, one of the colour templates, one of the preset themes or Custom.") unless template_offered?
|
|
12
12
|
|
|
13
13
|
Refusal.new("Choose each colour as a six-digit hex value, such as #1d4ed8.") unless colours_offered?
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def value
|
|
17
|
+
return { "preset_name" => @values[:template_name] } if preset?
|
|
17
18
|
return { "template_name" => @values[:template_name] } if @values[:template_name].present?
|
|
18
19
|
|
|
19
20
|
{ "accent" => @values[:accent], "surface" => @values[:surface], "text" => @values[:text] }
|
|
@@ -22,7 +23,11 @@ module KeystoneUi
|
|
|
22
23
|
private
|
|
23
24
|
|
|
24
25
|
def template_offered?
|
|
25
|
-
@values[:template_name].blank? || [ AppearanceChoice::DEFAULT_TEMPLATE, *KeystoneUi::Looks::Templates.names.map(&:to_s) ].include?(@values[:template_name])
|
|
26
|
+
@values[:template_name].blank? || preset? || [ AppearanceChoice::DEFAULT_TEMPLATE, *KeystoneUi::Looks::Templates.names.map(&:to_s) ].include?(@values[:template_name])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def preset?
|
|
30
|
+
KeystoneUi::Looks::Themes.names.map(&:to_s).include?(@values[:template_name].to_s)
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def colours_offered?
|
|
@@ -15,7 +15,7 @@ module KeystoneUi
|
|
|
15
15
|
|
|
16
16
|
return Refusal.new("This app does not let accounts choose their colours.") if colours_sent? && !KeystoneUi::Preferences.configuration.account_colors
|
|
17
17
|
|
|
18
|
-
chosen =
|
|
18
|
+
chosen = chosen_colours
|
|
19
19
|
return chosen.refusal if chosen&.refusal
|
|
20
20
|
|
|
21
21
|
keep("colours", (chosen ? { value: chosen.value } : {}).merge(switch(:members_choose)))
|
|
@@ -26,7 +26,17 @@ module KeystoneUi
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
28
|
def colours_sent?
|
|
29
|
-
@values.key?(:template_name)
|
|
29
|
+
@values.key?(:template_name) || sent_theme.sent?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def chosen_colours
|
|
33
|
+
return sent_theme.checked if sent_theme.sent?
|
|
34
|
+
|
|
35
|
+
ChosenColours.new(@values) if colours_sent?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sent_theme
|
|
39
|
+
@sent_theme ||= SentTheme.new(@values) { ComponentPreference.find_by(owner: @account, component_key: "colours")&.value.to_h }
|
|
30
40
|
end
|
|
31
41
|
|
|
32
42
|
def look_offered?
|
|
@@ -12,9 +12,10 @@ module KeystoneUi
|
|
|
12
12
|
def call
|
|
13
13
|
return Refusal.new("Choose Light, Dark, System or Custom for the mode.") unless mode_offered?
|
|
14
14
|
return Refusal.new("Choose the app's own look or one of the looks it offers.") unless look_offered?
|
|
15
|
-
return keep_mode_and_look unless @values.key?(:template_name)
|
|
15
|
+
return keep_mode_and_look unless @values.key?(:template_name) || sent_theme.sent?
|
|
16
16
|
return Refusal.new("This app sets the colours for everyone, so this was not saved.") unless KeystoneUi::Preferences.configuration.account_colors
|
|
17
17
|
return Refusal.new("Your account sets the colours for everyone, so this was not saved.") unless members_choose?
|
|
18
|
+
return keep_checked(sent_theme.checked) if sent_theme.sent?
|
|
18
19
|
return use_default if @values[:template_name] == AppearanceChoice::DEFAULT_TEMPLATE
|
|
19
20
|
|
|
20
21
|
chosen = ChosenColours.new(@values)
|
|
@@ -38,6 +39,17 @@ module KeystoneUi
|
|
|
38
39
|
LayoutChoice.new(person: @person, account: @account, component_key: "colours").members_choose?
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
def sent_theme
|
|
43
|
+
@sent_theme ||= SentTheme.new(@values) { preference.value.to_h }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def keep_checked(checked)
|
|
47
|
+
return checked.refusal if checked.refusal
|
|
48
|
+
|
|
49
|
+
preference.update!(value: checked.value)
|
|
50
|
+
keep_mode_and_look
|
|
51
|
+
end
|
|
52
|
+
|
|
41
53
|
def use_default
|
|
42
54
|
preference.destroy! if preference.persisted?
|
|
43
55
|
keep_mode_and_look
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class SentTheme
|
|
6
|
+
def initialize(values, &saved_colours)
|
|
7
|
+
@values = values
|
|
8
|
+
@saved_colours = saved_colours
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def sent?
|
|
12
|
+
pasting? || editing?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def checked
|
|
16
|
+
return pasted if pasting?
|
|
17
|
+
|
|
18
|
+
CheckedTheme.new(typed)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def pasting?
|
|
24
|
+
@values[:pasted_theme].present?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def editing?
|
|
28
|
+
typed.any? && [ saved["theme"], preset_theme(saved["preset_name"]), preset_theme(@values[:template_name]) ].exclude?(typed)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def saved
|
|
32
|
+
@saved ||= @saved_colours.call
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def preset_theme(name)
|
|
36
|
+
KeystoneUi::Looks::Themes[name][:theme] if KeystoneUi::Looks::Themes.names.map(&:to_s).include?(name.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def typed
|
|
40
|
+
@typed ||= @values[:theme].to_h.to_h { |name, sides| [ name.to_s, sides.to_h.to_h { |side, value| [ side.to_s, value.to_s.strip ] }.compact_blank ] }.compact_blank
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pasted
|
|
44
|
+
reader = ThemeReader.new(@values[:pasted_theme])
|
|
45
|
+
reader.refusal ? reader : CheckedTheme.new(reader.values)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class ThemeCss
|
|
6
|
+
VERSION = "2"
|
|
7
|
+
SHADOW_SETTINGS = %w[shadow-offset-x shadow-offset-y shadow-blur shadow-spread shadow-color shadow-opacity].freeze
|
|
8
|
+
SHADOWS = %w[--ks-shadow-surface --ks-shadow-overlay --ks-shadow-menu].freeze
|
|
9
|
+
HOVERS = { "primary" => "--ks-color-accent-hover", "secondary" => "--ks-color-neutral-hover", "destructive" => "--ks-color-danger-hover" }.freeze
|
|
10
|
+
|
|
11
|
+
OUTRANKING_LOOKS = ":root, html:root[data-look]"
|
|
12
|
+
|
|
13
|
+
def initialize(theme = {}, own_look: false, palette: nil)
|
|
14
|
+
@theme = theme
|
|
15
|
+
@own_look = own_look
|
|
16
|
+
@palette = palette
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
rules.map { |selector, lines| "#{selector} {\n#{lines.join("\n")}\n}" }.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def rules
|
|
26
|
+
return { ":root" => palette_lines } if @palette
|
|
27
|
+
return { OUTRANKING_LOOKS => colour_lines, ":root" => shape_lines } if @own_look
|
|
28
|
+
|
|
29
|
+
{ OUTRANKING_LOOKS => colour_lines + shape_lines }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def palette_lines
|
|
33
|
+
accent, surface, background, text = @palette.values_at(:accent, :surface, :background, :text)
|
|
34
|
+
shade_lines("accent", shades(accent) { KeystoneUi::Looks::Palettes.accent(accent) }) +
|
|
35
|
+
shade_lines("surface", shades(surface) { KeystoneUi::Looks::Palettes.surface(surface) }) +
|
|
36
|
+
[ declaration("--color-custom-background", background), declaration("--color-custom-text", text) ]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shades(colour)
|
|
40
|
+
return KeystoneUi::Looks::Palettes.generate_shades(colour) if colour.start_with?("#")
|
|
41
|
+
|
|
42
|
+
yield
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def shade_lines(name, shades)
|
|
46
|
+
shades.map { |shade, hex| declaration("--color-#{name}-#{shade}", hex) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def colour_lines
|
|
50
|
+
lines(@theme.select { |role, _sides| colour?(role) })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def shape_lines
|
|
54
|
+
lines(@theme.reject { |role, _sides| colour?(role) }) + shadow_declarations
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def colour?(role)
|
|
58
|
+
ThemeRoles::ROLES.fetch(role)[:kind] == :colour
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def lines(roles)
|
|
62
|
+
roles.flat_map do |role, sides|
|
|
63
|
+
ThemeRoles::ROLES.fetch(role)[:variables].flat_map { |variable| declarations(variable, sides) } + hover_declarations(role, sides)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def shadow_declarations
|
|
68
|
+
x, y, blur, spread, colour, opacity = SHADOW_SETTINGS.map { |setting| @theme.dig(setting, "light") }
|
|
69
|
+
return [] unless x && y && blur && spread && colour && opacity
|
|
70
|
+
|
|
71
|
+
SHADOWS.map { |variable| declaration(variable, "#{x} #{y} #{blur} #{spread} color-mix(in srgb, #{colour} calc(#{opacity} * 100%), transparent)") }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def hover_declarations(role, sides)
|
|
75
|
+
return [] unless HOVERS.key?(role)
|
|
76
|
+
|
|
77
|
+
declarations(HOVERS[role], sides.transform_values { |colour| "color-mix(in oklab, #{colour} 85%, white)" })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def declarations(variable, sides)
|
|
81
|
+
{ variable => sides["light"], "#{variable}-dark" => sides["dark"] }.compact.map { |name, value| declaration(name, value) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def declaration(name, value)
|
|
85
|
+
" #{name}: #{value};"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class ThemeReader
|
|
6
|
+
BLOCK = %r{(?:\A|(?<=[\};])|(?<=\*/)|@layer\s+[\w-]+\s*\{)\s*(:root|\.dark)\s*\{([^\{\}]*)\}}
|
|
7
|
+
DECLARATION = /--([a-z0-9-]+)\s*:\s*([^;]*)/
|
|
8
|
+
LIMIT = 20_000
|
|
9
|
+
FETCHES = /url\(|@import/i
|
|
10
|
+
SIDES = { ":root" => "light", ".dark" => "dark" }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(text)
|
|
13
|
+
@text = text.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def refusal
|
|
17
|
+
return Refusal.new("The pasted theme is longer than 20,000 characters, so it was not saved.") if @text.length > LIMIT
|
|
18
|
+
return Refusal.new("The pasted theme contains url( or @import, so it was not saved.") if @text.match?(FETCHES)
|
|
19
|
+
|
|
20
|
+
Refusal.new("The pasted theme has no shadcn colour role in a :root or .dark block, so it was not saved.") if values.keys.none? { |name| ThemeRoles::ROLES.fetch(name)[:kind] == :colour }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def values
|
|
24
|
+
@values ||= @text.scan(BLOCK).each_with_object({}) do |(selector, body), values|
|
|
25
|
+
body.scan(DECLARATION).each do |name, value|
|
|
26
|
+
next unless ThemeRoles::ROLES.key?(name)
|
|
27
|
+
|
|
28
|
+
(values[name] ||= {})[SIDES.fetch(selector)] = value.strip
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
module ThemeRoles
|
|
6
|
+
ROLES = {
|
|
7
|
+
"background" => { kind: :colour, variables: %w[--ks-color-page] },
|
|
8
|
+
"foreground" => { kind: :colour, variables: %w[--ks-color-on-page] },
|
|
9
|
+
"card" => { kind: :colour, variables: %w[--ks-color-surface] },
|
|
10
|
+
"card-foreground" => { kind: :colour, variables: %w[--ks-color-on-surface] },
|
|
11
|
+
"popover" => { kind: :colour, variables: %w[--ks-color-overlay] },
|
|
12
|
+
"popover-foreground" => { kind: :colour, variables: %w[--ks-color-on-overlay --ks-color-on-overlay-muted] },
|
|
13
|
+
"primary" => { kind: :colour, variables: %w[--ks-color-accent] },
|
|
14
|
+
"primary-foreground" => { kind: :colour, variables: %w[--ks-color-on-accent] },
|
|
15
|
+
"secondary" => { kind: :colour, variables: %w[--ks-color-neutral] },
|
|
16
|
+
"secondary-foreground" => { kind: :colour, variables: %w[--ks-color-on-neutral] },
|
|
17
|
+
"muted" => { kind: :colour, variables: %w[--ks-color-fill-muted] },
|
|
18
|
+
"muted-foreground" => { kind: :colour, variables: %w[--ks-color-text-muted] },
|
|
19
|
+
"accent" => { kind: :colour, variables: %w[--ks-color-hover] },
|
|
20
|
+
"accent-foreground" => { kind: :colour, variables: %w[--ks-color-on-hover --ks-color-on-hover-muted] },
|
|
21
|
+
"destructive" => { kind: :colour, variables: %w[--ks-color-danger] },
|
|
22
|
+
"destructive-foreground" => { kind: :colour, variables: %w[--ks-color-on-danger] },
|
|
23
|
+
"border" => { kind: :colour, variables: %w[--ks-color-border] },
|
|
24
|
+
"input" => { kind: :colour, variables: %w[--ks-color-border-control] },
|
|
25
|
+
"ring" => { kind: :colour, variables: %w[--ks-color-focus] },
|
|
26
|
+
"chart-1" => { kind: :colour, variables: %w[--ks-color-chart-1] },
|
|
27
|
+
"chart-2" => { kind: :colour, variables: %w[--ks-color-chart-2] },
|
|
28
|
+
"chart-3" => { kind: :colour, variables: %w[--ks-color-chart-3] },
|
|
29
|
+
"chart-4" => { kind: :colour, variables: %w[--ks-color-chart-4] },
|
|
30
|
+
"chart-5" => { kind: :colour, variables: %w[--ks-color-chart-5] },
|
|
31
|
+
"sidebar" => { kind: :colour, variables: %w[--ks-color-nav] },
|
|
32
|
+
"sidebar-foreground" => { kind: :colour, variables: %w[--ks-color-nav-link --ks-color-nav-text] },
|
|
33
|
+
"sidebar-primary" => { kind: :colour, variables: %w[--ks-color-nav-indicator --ks-color-nav-active] },
|
|
34
|
+
"sidebar-primary-foreground" => { kind: :colour, variables: [] },
|
|
35
|
+
"sidebar-accent" => { kind: :colour, variables: %w[--ks-color-nav-hover] },
|
|
36
|
+
"sidebar-accent-foreground" => { kind: :colour, variables: %w[--ks-color-nav-text-hover] },
|
|
37
|
+
"sidebar-border" => { kind: :colour, variables: %w[--ks-color-nav-border] },
|
|
38
|
+
"sidebar-ring" => { kind: :colour, variables: [] },
|
|
39
|
+
"radius" => { kind: :length, variables: %w[--ks-radius-control --ks-radius-surface] },
|
|
40
|
+
"spacing" => { kind: :length, variables: %w[--ks-spacing] },
|
|
41
|
+
"border-width" => { kind: :length, variables: %w[--ks-border-width] },
|
|
42
|
+
"letter-spacing" => { kind: :letter_spacing, variables: %w[--ks-letter-spacing] },
|
|
43
|
+
"font-sans" => { kind: :font_list, variables: %w[--ks-font-body] },
|
|
44
|
+
"font-serif" => { kind: :font_list, variables: [] },
|
|
45
|
+
"font-mono" => { kind: :font_list, variables: %w[--ks-font-mono --default-mono-font-family] },
|
|
46
|
+
"font-heading" => { kind: :font_list, variables: %w[--ks-font-heading] },
|
|
47
|
+
"font-weight-normal" => { kind: :weight, variables: %w[--ks-font-weight-normal] },
|
|
48
|
+
"font-weight-medium" => { kind: :weight, variables: %w[--ks-font-weight-medium] },
|
|
49
|
+
"font-weight-strong" => { kind: :weight, variables: %w[--ks-font-weight-strong] },
|
|
50
|
+
"font-weight-heading" => { kind: :weight, variables: %w[--ks-font-weight-heading] },
|
|
51
|
+
"shadow-color" => { kind: :shadow_colour, variables: [] },
|
|
52
|
+
"shadow-opacity" => { kind: :opacity, variables: [] },
|
|
53
|
+
"shadow-blur" => { kind: :length, variables: [] },
|
|
54
|
+
"shadow-spread" => { kind: :signed_length, variables: [] },
|
|
55
|
+
"shadow-offset-x" => { kind: :signed_length, variables: [] },
|
|
56
|
+
"shadow-offset-y" => { kind: :signed_length, variables: [] }
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
GROUPS = {
|
|
60
|
+
"Colors" => {
|
|
61
|
+
"Page" => %w[background foreground],
|
|
62
|
+
"Primary" => %w[primary primary-foreground],
|
|
63
|
+
"Secondary" => %w[secondary secondary-foreground],
|
|
64
|
+
"Accent" => %w[accent accent-foreground],
|
|
65
|
+
"Muted" => %w[muted muted-foreground],
|
|
66
|
+
"Card" => %w[card card-foreground],
|
|
67
|
+
"Popover" => %w[popover popover-foreground],
|
|
68
|
+
"Destructive" => %w[destructive destructive-foreground],
|
|
69
|
+
"Border, input and ring" => %w[border input ring],
|
|
70
|
+
"Chart" => %w[chart-1 chart-2 chart-3 chart-4 chart-5],
|
|
71
|
+
"Sidebar" => %w[sidebar sidebar-foreground sidebar-primary sidebar-primary-foreground sidebar-accent sidebar-accent-foreground sidebar-border sidebar-ring]
|
|
72
|
+
},
|
|
73
|
+
"Typography" => {
|
|
74
|
+
"Fonts" => %w[font-sans font-serif font-mono font-heading],
|
|
75
|
+
"Font weights" => %w[font-weight-normal font-weight-medium font-weight-strong font-weight-heading],
|
|
76
|
+
"Letter spacing" => %w[letter-spacing]
|
|
77
|
+
},
|
|
78
|
+
"Other" => {
|
|
79
|
+
"Shape" => %w[radius spacing border-width],
|
|
80
|
+
"Shadow" => %w[shadow-color shadow-opacity shadow-blur shadow-spread shadow-offset-x shadow-offset-y]
|
|
81
|
+
}
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
def self.names
|
|
85
|
+
ROLES.keys
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
offers_colours = KeystoneUi::Preferences.configuration.account_colors
|
|
6
6
|
offers_look = KeystoneUi::Preferences.configuration.account_looks && KeystoneUi.configuration.looks.any?
|
|
7
7
|
drawn = KeystoneUi::Preferences::AppearanceChoice.new(person: account, account: nil).colours
|
|
8
|
+
sent = request.get? ? {} : local_assigns[:selection].to_h
|
|
8
9
|
%>
|
|
9
10
|
|
|
10
11
|
<% if !offers_colours && !offers_look %>
|
|
11
12
|
<%= ui_alert(message: "This app does not let accounts choose their colours or look.") %>
|
|
12
13
|
<% else %>
|
|
14
|
+
<% editor = capture do %>
|
|
13
15
|
<%= ui_panel(padding: :lg) do %>
|
|
14
|
-
<%= form_with(url: submit_url, method: :patch, data: { turbo: false
|
|
16
|
+
<%= form_with(url: submit_url, method: :patch, data: { turbo: false }) do %>
|
|
15
17
|
<div style="display: flex; flex-direction: column; gap: 2rem;">
|
|
16
18
|
<% if offers_colours %>
|
|
17
|
-
<%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn } %>
|
|
19
|
+
<%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true, own_theme_hint: "The account's current choice is its own theme.", id_prefix: "account-appearance", sent_paste: sent[:pasted_theme] } %>
|
|
18
20
|
<% end %>
|
|
19
21
|
|
|
20
22
|
<% if offers_look %>
|
|
@@ -32,10 +34,20 @@
|
|
|
32
34
|
<% end %>
|
|
33
35
|
<% end %>
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
<% if offers_colours %>
|
|
38
|
+
<%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { saved: saved, id_prefix: "account-appearance", sent_theme: sent[:theme], sent_paste: sent[:pasted_theme] } %>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
36
42
|
<%= ui_button(label: "Save", type: :submit) %>
|
|
43
|
+
<% if offers_colours %>
|
|
44
|
+
<%= ui_button(label: "Reset", variant: :secondary, type: :button, data: { action: "keystone-ui--preferences--appearance-editor#reset" }) %>
|
|
45
|
+
<% end %>
|
|
37
46
|
</div>
|
|
38
47
|
</div>
|
|
39
48
|
<% end %>
|
|
40
49
|
<% end %>
|
|
41
50
|
<% end %>
|
|
51
|
+
|
|
52
|
+
<%= render partial: "keystone_ui/preferences/settings/editor_layout", locals: { editor: editor, previews: offers_colours } %>
|
|
53
|
+
<% end %>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
appearance = KeystoneUi::Preferences::AppearanceChoice.new(person: person, account: account)
|
|
6
6
|
drawn = appearance.colours
|
|
7
7
|
saved = KeystoneUi::Preferences::ComponentPreference.find_by(owner: person, component_key: "colours")&.value || { "template_name" => "default" }
|
|
8
|
+
sent = request.get? ? {} : local_assigns[:selection].to_h
|
|
8
9
|
saved_look = KeystoneUi::Preferences::ComponentPreference.find_by(owner: person, component_key: "look")&.value.to_h["look"]
|
|
9
10
|
%>
|
|
10
11
|
|
|
@@ -12,29 +13,40 @@
|
|
|
12
13
|
<%= ui_alert(message: "Your account sets the colours for everyone.") %>
|
|
13
14
|
<% end %>
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
<%=
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
<% editor = capture do %>
|
|
17
|
+
<%= ui_panel(padding: :lg) do %>
|
|
18
|
+
<%= form_with(url: submit_url, method: :patch, data: { turbo: false }) do %>
|
|
19
|
+
<div style="display: flex; flex-direction: column; gap: 2rem;">
|
|
20
|
+
<% if chooses_colours %>
|
|
21
|
+
<%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true, sent_paste: sent[:pasted_theme] } %>
|
|
22
|
+
<% end %>
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
<% if appearance.person_chooses_look? && KeystoneUi.configuration.looks.any? %>
|
|
25
|
+
<%= render partial: "keystone_ui/preferences/settings/look_choices", locals: { saved_look: saved_look } %>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<%= ui_section(title: "Mode", spacing: :sm) do %>
|
|
29
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
30
|
+
<% KeystoneUi::ThemeChoice::MODES.each do |mode| %>
|
|
31
|
+
<% next if mode == "custom" && !(chooses_colours || appearance.custom_offered?) %>
|
|
32
|
+
<%= ui_radio_card(name: "mode", value: mode, label: mode.capitalize, checked: appearance.mode == mode) %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<% if chooses_colours %>
|
|
38
|
+
<%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { saved: saved, sent_theme: sent[:theme], sent_paste: sent[:pasted_theme] } %>
|
|
39
|
+
<% end %>
|
|
25
40
|
|
|
26
|
-
<%= ui_section(title: "Mode", spacing: :sm) do %>
|
|
27
41
|
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<%=
|
|
42
|
+
<%= ui_button(label: "Save", type: :submit) %>
|
|
43
|
+
<% if chooses_colours %>
|
|
44
|
+
<%= ui_button(label: "Reset", variant: :secondary, type: :button, data: { action: "keystone-ui--preferences--appearance-editor#reset" }) %>
|
|
31
45
|
<% end %>
|
|
32
46
|
</div>
|
|
33
|
-
<% end %>
|
|
34
|
-
|
|
35
|
-
<div>
|
|
36
|
-
<%= ui_button(label: "Save", type: :submit) %>
|
|
37
47
|
</div>
|
|
38
|
-
|
|
48
|
+
<% end %>
|
|
39
49
|
<% end %>
|
|
40
50
|
<% end %>
|
|
51
|
+
|
|
52
|
+
<%= render partial: "keystone_ui/preferences/settings/editor_layout", locals: { editor: editor, previews: chooses_colours } %>
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
value: name,
|
|
16
16
|
selected: saved["template_name"] == name,
|
|
17
17
|
input_data: {
|
|
18
|
-
action: "keystone-ui--preferences--
|
|
18
|
+
action: "keystone-ui--preferences--appearance-editor#selectTemplate",
|
|
19
19
|
"accent-hex": accent_hex,
|
|
20
20
|
"surface-hex": surface_hex,
|
|
21
21
|
"text-hex": template[:text]
|
|
22
22
|
},
|
|
23
23
|
label_data: {
|
|
24
|
-
"keystone-ui--preferences--
|
|
24
|
+
"keystone-ui--preferences--appearance-editor-target": "templateLabel",
|
|
25
25
|
template: name
|
|
26
26
|
}
|
|
27
27
|
) do %>
|
|
@@ -30,37 +30,69 @@
|
|
|
30
30
|
<span><%= template[:label] %></span>
|
|
31
31
|
<% end %>
|
|
32
32
|
<% end %>
|
|
33
|
+
<% if local_assigns[:presets] %>
|
|
34
|
+
<% KeystoneUi::Looks::Themes.all.each do |name, preset| %>
|
|
35
|
+
<%= ui_option_card(
|
|
36
|
+
name: "template_name",
|
|
37
|
+
value: name.to_s,
|
|
38
|
+
selected: saved["preset_name"] == name.to_s,
|
|
39
|
+
input_data: {
|
|
40
|
+
action: "keystone-ui--preferences--appearance-editor#selectPreset",
|
|
41
|
+
theme: preset[:theme].to_json
|
|
42
|
+
},
|
|
43
|
+
label_data: {
|
|
44
|
+
"keystone-ui--preferences--appearance-editor-target": "templateLabel",
|
|
45
|
+
template: name.to_s
|
|
46
|
+
}
|
|
47
|
+
) do %>
|
|
48
|
+
<span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 9999px; background-color: <%= preset[:theme].dig("primary", "light") %>;"></span>
|
|
49
|
+
<span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 0.25rem; background-color: <%= preset[:theme].dig("secondary", "light") %>;"></span>
|
|
50
|
+
<span><%= preset[:label] %></span>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
33
54
|
<%= ui_option_card(
|
|
34
55
|
name: "template_name",
|
|
35
56
|
value: "",
|
|
36
|
-
selected: saved["template_name"].blank
|
|
57
|
+
selected: saved["template_name"].blank? && !saved.key?("theme") && !saved.key?("preset_name"),
|
|
37
58
|
input_data: {
|
|
38
|
-
"keystone-ui--preferences--
|
|
59
|
+
"keystone-ui--preferences--appearance-editor-target": "customRadio"
|
|
39
60
|
},
|
|
40
61
|
label_data: {
|
|
41
|
-
"keystone-ui--preferences--
|
|
62
|
+
"keystone-ui--preferences--appearance-editor-target": "templateLabel",
|
|
42
63
|
template: "custom"
|
|
43
64
|
}
|
|
44
65
|
) do %>
|
|
45
66
|
<span>Custom</span>
|
|
46
67
|
<% end %>
|
|
47
68
|
</div>
|
|
69
|
+
<% if saved.key?("theme") %>
|
|
70
|
+
<p class="ks-hint"><%= local_assigns.fetch(:own_theme_hint, "Your current choice is your own theme.") %></p>
|
|
71
|
+
<% end %>
|
|
72
|
+
<% if local_assigns[:pastes] %>
|
|
73
|
+
<% id_prefix = local_assigns.fetch(:id_prefix, "appearance") %>
|
|
74
|
+
<div class="<%= Keystone::Ui::FormFieldComponent::WRAPPER_CLASSES %>">
|
|
75
|
+
<%= label_tag "#{id_prefix}-pasted-theme", "Paste a theme", class: Keystone::Ui::FormFieldComponent::LABEL_CLASSES %>
|
|
76
|
+
<%= text_area_tag "pasted_theme", local_assigns[:sent_paste], id: "#{id_prefix}-pasted-theme", rows: 3, class: Keystone::Ui::TextareaComponent::BASE_CLASSES %>
|
|
77
|
+
<p class="<%= Keystone::Ui::FormFieldComponent::HINT_CLASSES %>">A :root block and a .dark block in shadcn/ui's variable names, as tweakcn and shadcn's theme pages export them.</p>
|
|
78
|
+
</div>
|
|
79
|
+
<% end %>
|
|
48
80
|
<% end %>
|
|
49
81
|
|
|
50
82
|
<%= ui_section(title: "Accent Color", spacing: :sm) do %>
|
|
51
|
-
<div data-keystone-ui--preferences--
|
|
83
|
+
<div data-keystone-ui--preferences--appearance-editor-target="accentPicker">
|
|
52
84
|
<%= ui_color_picker(name: "accent", value: palette_hex.call(drawn[:accent], KeystoneUi::Looks::Palettes.method(:accent))) %>
|
|
53
85
|
</div>
|
|
54
86
|
<% end %>
|
|
55
87
|
|
|
56
88
|
<%= ui_section(title: "Surface", spacing: :sm) do %>
|
|
57
|
-
<div data-keystone-ui--preferences--
|
|
89
|
+
<div data-keystone-ui--preferences--appearance-editor-target="surfacePicker">
|
|
58
90
|
<%= ui_color_picker(name: "surface", value: palette_hex.call(drawn[:surface], KeystoneUi::Looks::Palettes.method(:surface))) %>
|
|
59
91
|
</div>
|
|
60
92
|
<% end %>
|
|
61
93
|
|
|
62
94
|
<%= ui_section(title: "Text Color", spacing: :sm) do %>
|
|
63
|
-
<div data-keystone-ui--preferences--
|
|
95
|
+
<div data-keystone-ui--preferences--appearance-editor-target="textPicker">
|
|
64
96
|
<%= ui_color_picker(name: "text", value: drawn[:text]) %>
|
|
65
97
|
</div>
|
|
66
98
|
<% end %>
|