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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +54 -27
  3. data/app/controllers/concerns/keystone_ui/preferences/component_preferences.rb +2 -2
  4. data/app/javascript/keystone_ui/preferences/appearance_editor_controller.js +235 -0
  5. data/app/models/keystone_ui/preferences/appearance_choice.rb +66 -1
  6. data/app/models/keystone_ui/preferences/checked_theme.rb +109 -0
  7. data/app/models/keystone_ui/preferences/chosen_colours.rb +7 -2
  8. data/app/models/keystone_ui/preferences/pick_account_appearance.rb +12 -2
  9. data/app/models/keystone_ui/preferences/pick_appearance.rb +13 -1
  10. data/app/models/keystone_ui/preferences/sent_theme.rb +49 -0
  11. data/app/models/keystone_ui/preferences/theme_css.rb +89 -0
  12. data/app/models/keystone_ui/preferences/theme_reader.rb +34 -0
  13. data/app/models/keystone_ui/preferences/theme_roles.rb +89 -0
  14. data/app/views/keystone_ui/preferences/settings/_account_appearance.html.erb +15 -3
  15. data/app/views/keystone_ui/preferences/settings/_appearance.html.erb +30 -18
  16. data/app/views/keystone_ui/preferences/settings/_colour_choices.html.erb +40 -8
  17. data/app/views/keystone_ui/preferences/settings/_editor_layout.html.erb +29 -0
  18. data/app/views/keystone_ui/preferences/settings/_theme_preview.html.erb +34 -0
  19. data/app/views/keystone_ui/preferences/settings/_theme_settings.html.erb +52 -0
  20. data/lib/generators/keystone_ui/preferences/install/install_generator.rb +4 -4
  21. data/lib/generators/keystone_ui/preferences/update/update_generator.rb +4 -4
  22. data/lib/keystone_ui/preferences/colors_check.rb +3 -2
  23. data/lib/keystone_ui/preferences/configuration.rb +2 -1
  24. data/lib/keystone_ui/preferences/version.rb +1 -1
  25. metadata +28 -7
  26. data/app/javascript/keystone_ui/preferences/theme_settings_controller.js +0 -59
  27. 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 = ChosenColours.new(@values) if colours_sent?
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, controller: "keystone-ui--preferences--theme-settings" }) do %>
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
- <div>
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
- <%= 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 } %>
20
- <% end %>
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
- <% 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 %>
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
- <% 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) %>
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
- </div>
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--theme-settings#selectTemplate",
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--theme-settings-target": "templateLabel",
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--theme-settings-target": "customRadio"
59
+ "keystone-ui--preferences--appearance-editor-target": "customRadio"
39
60
  },
40
61
  label_data: {
41
- "keystone-ui--preferences--theme-settings-target": "templateLabel",
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--theme-settings-target="accentPicker">
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--theme-settings-target="surfacePicker">
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--theme-settings-target="textPicker">
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 %>