keystone_ui-preferences 0.6.0 → 0.8.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 +163 -3
- data/app/controllers/concerns/keystone_ui/preferences/component_preferences.rb +36 -1
- data/app/javascript/keystone_ui/preferences/theme_settings_controller.js +84 -0
- data/app/models/keystone_ui/preferences/appearance_choice.rb +172 -0
- data/app/models/keystone_ui/preferences/checked_theme.rb +109 -0
- data/app/models/keystone_ui/preferences/chosen_colours.rb +44 -0
- data/app/models/keystone_ui/preferences/layout_choice.rb +6 -2
- data/app/models/keystone_ui/preferences/pick_account_appearance.rb +64 -0
- data/app/models/keystone_ui/preferences/pick_appearance.rb +90 -0
- 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 +64 -0
- data/app/views/keystone_ui/preferences/settings/_account_appearance.html.erb +41 -0
- data/app/views/keystone_ui/preferences/settings/_appearance.html.erb +40 -0
- data/app/views/keystone_ui/preferences/settings/_colour_choices.html.erb +95 -0
- data/app/views/keystone_ui/preferences/settings/_look_choices.html.erb +8 -0
- data/app/views/keystone_ui/preferences/settings/_theme_settings.html.erb +17 -0
- data/lib/generators/keystone_ui/preferences/install/install_generator.rb +6 -1
- data/lib/generators/keystone_ui/preferences/update/templates/copy_keystone_ui_colors_theme_preferences.rb.erb +55 -0
- data/lib/generators/keystone_ui/preferences/update/update_generator.rb +15 -1
- data/lib/keystone_ui/preferences/colors_check.rb +22 -0
- data/lib/keystone_ui/preferences/configuration.rb +11 -1
- data/lib/keystone_ui/preferences/engine.rb +24 -0
- data/lib/keystone_ui/preferences/version.rb +1 -1
- data/lib/keystone_ui/preferences.rb +1 -0
- metadata +48 -3
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class ChosenColours
|
|
6
|
+
def initialize(values)
|
|
7
|
+
@values = values
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def refusal
|
|
11
|
+
return Refusal.new("Choose Default, one of the colour templates, one of the preset themes or Custom.") unless template_offered?
|
|
12
|
+
|
|
13
|
+
Refusal.new("Choose each colour as a six-digit hex value, such as #1d4ed8.") unless colours_offered?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def value
|
|
17
|
+
return { "preset_name" => @values[:template_name] } if preset?
|
|
18
|
+
return { "template_name" => @values[:template_name] } if @values[:template_name].present?
|
|
19
|
+
|
|
20
|
+
{ "accent" => @values[:accent], "surface" => @values[:surface], "text" => @values[:text] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def template_offered?
|
|
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)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def colours_offered?
|
|
34
|
+
return true if @values[:template_name].present?
|
|
35
|
+
|
|
36
|
+
colour_offered?(@values[:accent], KeystoneUi::Looks::Palettes::ACCENTS) && colour_offered?(@values[:surface], KeystoneUi::Looks::Palettes::SURFACES) && colour_offered?(@values[:text], {})
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def colour_offered?(colour, palettes)
|
|
40
|
+
colour.to_s.match?(AppearanceChoice::HEX) || palettes.key?(colour.to_s.to_sym)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -12,9 +12,13 @@ module KeystoneUi
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def value
|
|
15
|
-
|
|
15
|
+
preference&.value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def preference
|
|
19
|
+
return account_preference unless members_choose?
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
own_preference || account_preference
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
def members_choose?
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class PickAccountAppearance
|
|
6
|
+
def initialize(values:, person: nil, account: nil)
|
|
7
|
+
@person = person
|
|
8
|
+
@account = account
|
|
9
|
+
@values = values
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
return Refusal.new("There is no account to save this appearance on.") unless @account
|
|
14
|
+
return Refusal.new("Choose the app's own look or one of the looks it offers.") unless look_offered?
|
|
15
|
+
|
|
16
|
+
return Refusal.new("This app does not let accounts choose their colours.") if colours_sent? && !KeystoneUi::Preferences.configuration.account_colors
|
|
17
|
+
|
|
18
|
+
chosen = chosen_colours
|
|
19
|
+
return chosen.refusal if chosen&.refusal
|
|
20
|
+
|
|
21
|
+
keep("colours", (chosen ? { value: chosen.value } : {}).merge(switch(:members_choose)))
|
|
22
|
+
keep_look
|
|
23
|
+
Kept.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def colours_sent?
|
|
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 }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def look_offered?
|
|
43
|
+
!look_sent? || @values[:look].blank? || KeystoneUi.configuration.looks.key?(@values[:look])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def keep_look
|
|
47
|
+
chosen = look_sent? ? { value: { "look" => @values[:look].presence }.compact } : {}
|
|
48
|
+
keep("look", chosen.merge(switch(:members_choose_look)))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def look_sent?
|
|
52
|
+
@values.key?(:look) && KeystoneUi::Preferences.configuration.account_looks
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def keep(component_key, changes)
|
|
56
|
+
ComponentPreference.find_or_initialize_by(owner: @account, component_key: component_key).update!(changes) if changes.any?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def switch(name)
|
|
60
|
+
@values.key?(name) ? { members_choose: @values[name] == "1" } : {}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class PickAppearance
|
|
6
|
+
def initialize(values:, person: nil, account: nil)
|
|
7
|
+
@person = person
|
|
8
|
+
@account = account
|
|
9
|
+
@values = values
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
return Refusal.new("Choose Light, Dark, System or Custom for the mode.") unless mode_offered?
|
|
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) || sent_theme.sent?
|
|
16
|
+
return Refusal.new("This app sets the colours for everyone, so this was not saved.") unless KeystoneUi::Preferences.configuration.account_colors
|
|
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?
|
|
19
|
+
return use_default if @values[:template_name] == AppearanceChoice::DEFAULT_TEMPLATE
|
|
20
|
+
|
|
21
|
+
chosen = ChosenColours.new(@values)
|
|
22
|
+
return chosen.refusal if chosen.refusal
|
|
23
|
+
|
|
24
|
+
preference.update!(value: chosen.value)
|
|
25
|
+
keep_mode_and_look
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def mode_offered?
|
|
31
|
+
!@values.key?(:mode) || KeystoneUi::ThemeChoice::MODES.include?(@values[:mode])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def look_offered?
|
|
35
|
+
!look_sent? || @values[:look].blank? || KeystoneUi.configuration.looks.key?(@values[:look])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def members_choose?
|
|
39
|
+
LayoutChoice.new(person: @person, account: @account, component_key: "colours").members_choose?
|
|
40
|
+
end
|
|
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
|
+
|
|
53
|
+
def use_default
|
|
54
|
+
preference.destroy! if preference.persisted?
|
|
55
|
+
keep_mode_and_look
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def keep_mode_and_look
|
|
59
|
+
keep_mode
|
|
60
|
+
keep_look
|
|
61
|
+
Kept.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def keep_mode
|
|
65
|
+
return unless @values.key?(:mode)
|
|
66
|
+
|
|
67
|
+
ComponentPreference.find_or_initialize_by(owner: @person, component_key: "mode").update!(value: { "mode" => @values[:mode] })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def keep_look
|
|
71
|
+
return unless look_sent?
|
|
72
|
+
return look_preference.update!(value: { "look" => @values[:look] }) if @values[:look].present?
|
|
73
|
+
|
|
74
|
+
look_preference.destroy! if look_preference.persisted?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def look_sent?
|
|
78
|
+
@values.key?(:look) && AppearanceChoice.new(person: @person, account: @account).person_chooses_look?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def look_preference
|
|
82
|
+
@look_preference ||= ComponentPreference.find_or_initialize_by(owner: @person, component_key: "look")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def preference
|
|
86
|
+
@preference ||= ComponentPreference.find_or_initialize_by(owner: @person, component_key: "colours")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -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,64 @@
|
|
|
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
|
+
def self.names
|
|
60
|
+
ROLES.keys
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%
|
|
2
|
+
colours = KeystoneUi::Preferences::ComponentPreference.find_by(owner: account, component_key: "colours")
|
|
3
|
+
saved = colours&.value.presence || { "template_name" => "default" }
|
|
4
|
+
look = KeystoneUi::Preferences::ComponentPreference.find_by(owner: account, component_key: "look")
|
|
5
|
+
offers_colours = KeystoneUi::Preferences.configuration.account_colors
|
|
6
|
+
offers_look = KeystoneUi::Preferences.configuration.account_looks && KeystoneUi.configuration.looks.any?
|
|
7
|
+
drawn = KeystoneUi::Preferences::AppearanceChoice.new(person: account, account: nil).colours
|
|
8
|
+
%>
|
|
9
|
+
|
|
10
|
+
<% if !offers_colours && !offers_look %>
|
|
11
|
+
<%= ui_alert(message: "This app does not let accounts choose their colours or look.") %>
|
|
12
|
+
<% else %>
|
|
13
|
+
<%= ui_panel(padding: :lg) do %>
|
|
14
|
+
<%= form_with(url: submit_url, method: :patch, data: { turbo: false, controller: "keystone-ui--preferences--theme-settings" }) do %>
|
|
15
|
+
<div style="display: flex; flex-direction: column; gap: 2rem;">
|
|
16
|
+
<% if offers_colours %>
|
|
17
|
+
<%= 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." } %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<% if offers_look %>
|
|
21
|
+
<%= render partial: "keystone_ui/preferences/settings/look_choices", locals: { saved_look: look&.value.to_h["look"] } %>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<%= ui_section(title: "Members", spacing: :sm) do %>
|
|
25
|
+
<% if offers_colours %>
|
|
26
|
+
<%= hidden_field_tag "members_choose", "0", id: nil %>
|
|
27
|
+
<%= ui_checkbox_row(name: "members_choose", value: "1", label: "Members choose their own colours", checked: colours.nil? || colours.members_choose) %>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% if offers_look %>
|
|
30
|
+
<%= hidden_field_tag "members_choose_look", "0", id: nil %>
|
|
31
|
+
<%= ui_checkbox_row(name: "members_choose_look", value: "1", label: "Members choose their own look", checked: look.nil? || look.members_choose) %>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<div>
|
|
36
|
+
<%= ui_button(label: "Save", type: :submit) %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% end %>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%
|
|
2
|
+
choice = KeystoneUi::Preferences::LayoutChoice.new(person: person, account: account, component_key: "colours")
|
|
3
|
+
configuration = KeystoneUi::Preferences.configuration
|
|
4
|
+
chooses_colours = configuration.account_colors && choice.members_choose?
|
|
5
|
+
appearance = KeystoneUi::Preferences::AppearanceChoice.new(person: person, account: account)
|
|
6
|
+
drawn = appearance.colours
|
|
7
|
+
saved = KeystoneUi::Preferences::ComponentPreference.find_by(owner: person, component_key: "colours")&.value || { "template_name" => "default" }
|
|
8
|
+
saved_look = KeystoneUi::Preferences::ComponentPreference.find_by(owner: person, component_key: "look")&.value.to_h["look"]
|
|
9
|
+
%>
|
|
10
|
+
|
|
11
|
+
<% if configuration.account_colors && !choice.members_choose? %>
|
|
12
|
+
<%= ui_alert(message: "Your account sets the colours for everyone.") %>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<%= ui_panel(padding: :lg) do %>
|
|
16
|
+
<%= form_with(url: submit_url, method: :patch, data: { turbo: false, controller: "keystone-ui--preferences--theme-settings" }) do %>
|
|
17
|
+
<div style="display: flex; flex-direction: column; gap: 2rem;">
|
|
18
|
+
<% if chooses_colours %>
|
|
19
|
+
<%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true } %>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if appearance.person_chooses_look? && KeystoneUi.configuration.looks.any? %>
|
|
23
|
+
<%= render partial: "keystone_ui/preferences/settings/look_choices", locals: { saved_look: saved_look } %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<%= ui_section(title: "Mode", spacing: :sm) do %>
|
|
27
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
28
|
+
<% KeystoneUi::ThemeChoice::MODES.each do |mode| %>
|
|
29
|
+
<% next if mode == "custom" && !(chooses_colours || appearance.custom_offered?) %>
|
|
30
|
+
<%= ui_radio_card(name: "mode", value: mode, label: mode.capitalize, checked: appearance.mode == mode) %>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<div>
|
|
36
|
+
<%= ui_button(label: "Save", type: :submit) %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<%
|
|
2
|
+
configuration = KeystoneUi::Preferences.configuration
|
|
3
|
+
palette_hex = ->(colour, palette) { colour.to_s.start_with?("#") ? colour.to_s : palette.call(colour)[500] }
|
|
4
|
+
templates = { "default" => { accent: configuration.default_accent, surface: configuration.default_surface, text: configuration.default_text, label: "Default" } }
|
|
5
|
+
KeystoneUi::Looks::Templates.all.each { |name, template| templates[name.to_s] = template }
|
|
6
|
+
saved_preset = KeystoneUi::Looks::Themes.all.find { |name, _preset| name.to_s == saved["preset_name"] }&.last
|
|
7
|
+
%>
|
|
8
|
+
|
|
9
|
+
<%= ui_section(title: "Theme", spacing: :sm) do %>
|
|
10
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
11
|
+
<% templates.each do |name, template| %>
|
|
12
|
+
<% accent_hex = palette_hex.call(template[:accent], KeystoneUi::Looks::Palettes.method(:accent)) %>
|
|
13
|
+
<% surface_hex = palette_hex.call(template[:surface], KeystoneUi::Looks::Palettes.method(:surface)) %>
|
|
14
|
+
<%= ui_option_card(
|
|
15
|
+
name: "template_name",
|
|
16
|
+
value: name,
|
|
17
|
+
selected: saved["template_name"] == name,
|
|
18
|
+
input_data: {
|
|
19
|
+
action: "keystone-ui--preferences--theme-settings#selectTemplate",
|
|
20
|
+
"accent-hex": accent_hex,
|
|
21
|
+
"surface-hex": surface_hex,
|
|
22
|
+
"text-hex": template[:text]
|
|
23
|
+
},
|
|
24
|
+
label_data: {
|
|
25
|
+
"keystone-ui--preferences--theme-settings-target": "templateLabel",
|
|
26
|
+
template: name
|
|
27
|
+
}
|
|
28
|
+
) do %>
|
|
29
|
+
<span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 9999px; background-color: <%= accent_hex %>;"></span>
|
|
30
|
+
<span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 0.25rem; background-color: <%= surface_hex %>;"></span>
|
|
31
|
+
<span><%= template[:label] %></span>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if local_assigns[:presets] %>
|
|
35
|
+
<% KeystoneUi::Looks::Themes.all.each do |name, preset| %>
|
|
36
|
+
<%= ui_option_card(
|
|
37
|
+
name: "template_name",
|
|
38
|
+
value: name.to_s,
|
|
39
|
+
selected: saved["preset_name"] == name.to_s,
|
|
40
|
+
input_data: {
|
|
41
|
+
action: "keystone-ui--preferences--theme-settings#selectPreset",
|
|
42
|
+
theme: preset[:theme].to_json
|
|
43
|
+
},
|
|
44
|
+
label_data: {
|
|
45
|
+
"keystone-ui--preferences--theme-settings-target": "templateLabel",
|
|
46
|
+
template: name.to_s
|
|
47
|
+
}
|
|
48
|
+
) do %>
|
|
49
|
+
<span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 9999px; background-color: <%= preset[:theme].dig("primary", "light") %>;"></span>
|
|
50
|
+
<span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 0.25rem; background-color: <%= preset[:theme].dig("secondary", "light") %>;"></span>
|
|
51
|
+
<span><%= preset[:label] %></span>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% end %>
|
|
55
|
+
<%= ui_option_card(
|
|
56
|
+
name: "template_name",
|
|
57
|
+
value: "",
|
|
58
|
+
selected: saved["template_name"].blank? && !saved.key?("theme") && !saved.key?("preset_name"),
|
|
59
|
+
input_data: {
|
|
60
|
+
"keystone-ui--preferences--theme-settings-target": "customRadio"
|
|
61
|
+
},
|
|
62
|
+
label_data: {
|
|
63
|
+
"keystone-ui--preferences--theme-settings-target": "templateLabel",
|
|
64
|
+
template: "custom"
|
|
65
|
+
}
|
|
66
|
+
) do %>
|
|
67
|
+
<span>Custom</span>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
<% if saved.key?("theme") %>
|
|
71
|
+
<p class="ks-hint"><%= local_assigns.fetch(:own_theme_hint, "Your current choice is your own theme.") %></p>
|
|
72
|
+
<% end %>
|
|
73
|
+
<% if local_assigns[:pastes] %>
|
|
74
|
+
<%= ui_form_field(attribute: "pasted_theme", label: "Paste a theme", type: :textarea, hint: "A :root block and a .dark block in shadcn/ui's variable names, as tweakcn and shadcn's theme pages export them.") %>
|
|
75
|
+
<%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { theme: saved_preset ? saved_preset[:theme] : saved["theme"].to_h } %>
|
|
76
|
+
<% end %>
|
|
77
|
+
<% end %>
|
|
78
|
+
|
|
79
|
+
<%= ui_section(title: "Accent Color", spacing: :sm) do %>
|
|
80
|
+
<div data-keystone-ui--preferences--theme-settings-target="accentPicker">
|
|
81
|
+
<%= ui_color_picker(name: "accent", value: palette_hex.call(drawn[:accent], KeystoneUi::Looks::Palettes.method(:accent))) %>
|
|
82
|
+
</div>
|
|
83
|
+
<% end %>
|
|
84
|
+
|
|
85
|
+
<%= ui_section(title: "Surface", spacing: :sm) do %>
|
|
86
|
+
<div data-keystone-ui--preferences--theme-settings-target="surfacePicker">
|
|
87
|
+
<%= ui_color_picker(name: "surface", value: palette_hex.call(drawn[:surface], KeystoneUi::Looks::Palettes.method(:surface))) %>
|
|
88
|
+
</div>
|
|
89
|
+
<% end %>
|
|
90
|
+
|
|
91
|
+
<%= ui_section(title: "Text Color", spacing: :sm) do %>
|
|
92
|
+
<div data-keystone-ui--preferences--theme-settings-target="textPicker">
|
|
93
|
+
<%= ui_color_picker(name: "text", value: drawn[:text]) %>
|
|
94
|
+
</div>
|
|
95
|
+
<% end %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%= ui_section(title: "Look", spacing: :sm) do %>
|
|
2
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
3
|
+
<%= ui_radio_card(name: "look", value: "", label: KeystoneUi::Preferences.configuration.app_look_label, checked: saved_look.nil?) %>
|
|
4
|
+
<% KeystoneUi.configuration.looks.each_key do |look| %>
|
|
5
|
+
<%= ui_radio_card(name: "look", value: look, label: look.humanize, checked: saved_look == look) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%= ui_disclosure do |disclosure| %>
|
|
2
|
+
<% disclosure.with_summary { "Change each theme setting" } %>
|
|
3
|
+
<div style="display: flex; flex-wrap: wrap; gap: 1rem;">
|
|
4
|
+
<% KeystoneUi::Preferences::ThemeRoles::ROLES.each do |name, role| %>
|
|
5
|
+
<% (role[:kind] == :colour ? %w[light dark] : %w[light]).each do |side| %>
|
|
6
|
+
<% field_name = "theme[#{name}][#{side}]" %>
|
|
7
|
+
<% value = theme.dig(name, side).to_s %>
|
|
8
|
+
<% label = role[:kind] == :colour ? "--#{name}, #{side}" : "--#{name}" %>
|
|
9
|
+
<% if %i[colour shadow_colour].include?(role[:kind]) %>
|
|
10
|
+
<%= ui_color_picker(name: field_name, value: value, label: label) %>
|
|
11
|
+
<% else %>
|
|
12
|
+
<%= ui_form_field(attribute: field_name, label: label, value: value) %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|