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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -3
  3. data/app/controllers/concerns/keystone_ui/preferences/component_preferences.rb +36 -1
  4. data/app/javascript/keystone_ui/preferences/theme_settings_controller.js +84 -0
  5. data/app/models/keystone_ui/preferences/appearance_choice.rb +172 -0
  6. data/app/models/keystone_ui/preferences/checked_theme.rb +109 -0
  7. data/app/models/keystone_ui/preferences/chosen_colours.rb +44 -0
  8. data/app/models/keystone_ui/preferences/layout_choice.rb +6 -2
  9. data/app/models/keystone_ui/preferences/pick_account_appearance.rb +64 -0
  10. data/app/models/keystone_ui/preferences/pick_appearance.rb +90 -0
  11. data/app/models/keystone_ui/preferences/sent_theme.rb +49 -0
  12. data/app/models/keystone_ui/preferences/theme_css.rb +89 -0
  13. data/app/models/keystone_ui/preferences/theme_reader.rb +34 -0
  14. data/app/models/keystone_ui/preferences/theme_roles.rb +64 -0
  15. data/app/views/keystone_ui/preferences/settings/_account_appearance.html.erb +41 -0
  16. data/app/views/keystone_ui/preferences/settings/_appearance.html.erb +40 -0
  17. data/app/views/keystone_ui/preferences/settings/_colour_choices.html.erb +95 -0
  18. data/app/views/keystone_ui/preferences/settings/_look_choices.html.erb +8 -0
  19. data/app/views/keystone_ui/preferences/settings/_theme_settings.html.erb +17 -0
  20. data/lib/generators/keystone_ui/preferences/install/install_generator.rb +6 -1
  21. data/lib/generators/keystone_ui/preferences/update/templates/copy_keystone_ui_colors_theme_preferences.rb.erb +55 -0
  22. data/lib/generators/keystone_ui/preferences/update/update_generator.rb +15 -1
  23. data/lib/keystone_ui/preferences/colors_check.rb +22 -0
  24. data/lib/keystone_ui/preferences/configuration.rb +11 -1
  25. data/lib/keystone_ui/preferences/engine.rb +24 -0
  26. data/lib/keystone_ui/preferences/version.rb +1 -1
  27. data/lib/keystone_ui/preferences.rb +1 -0
  28. 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
- return account_preference.value unless members_choose?
15
+ preference&.value
16
+ end
17
+
18
+ def preference
19
+ return account_preference unless members_choose?
16
20
 
17
- (own_preference || account_preference)&.value
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 %>