keystone_ui-colors 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a6fe78366470d37cb8598bfa4181561c42eca52a409dd7648246c397584daad9
4
+ data.tar.gz: dbbcf12bbc4fd745c85cf35a94d0608cabfa9565e7a92e87eb22a5a5faa17f01
5
+ SHA512:
6
+ metadata.gz: 9764dcad37f39f954cc377abf737bad185b2ee9e1e0ce301982bd6a52d286a0069ca8513b5afb918df2411475b40040646c5e605a6c384edf8b88e3f3c5c4d9b
7
+ data.tar.gz: 60f59e26247a9580e2422d873f0e8cb4d4daa2b9aecd5dda755972b76f7dbfb46be9cf07bed75954fb2ee591a0699ac94285e65ebf445611a8f8443e84ada4a9
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 Tyler Schneider
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # KeystoneUi::Colors
2
+
3
+ A Rails engine that adds per-user color palette persistence and preset themes. Companion to [keystone_ui](https://github.com/DYB-Development/keystone_ui).
4
+
5
+ Users pick from preset themes or custom hex colors. The gem generates CSS custom properties (`--color-accent-*`, `--color-surface-*`) and injects them via a `<style>` tag -- no frontend build step required.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby >= 3.1
10
+ - Rails >= 7.0
11
+ - [keystone_ui](https://github.com/DYB-Development/keystone_ui) >= 0.4.1
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "keystone_ui-colors"
19
+ ```
20
+
21
+ Run the install generator:
22
+
23
+ ```bash
24
+ bin/rails generate keystone_ui:colors:install
25
+ bin/rails db:migrate
26
+ ```
27
+
28
+ This creates:
29
+ - A migration for `keystone_ui_colors_theme_preferences`
30
+ - A Stimulus controller at `app/javascript/controllers/keystone_ui/colors/theme_settings_controller.js`
31
+
32
+ ### Manual setup
33
+
34
+ **1. Mount the engine** in `config/routes.rb`:
35
+
36
+ ```ruby
37
+ mount KeystoneUi::Colors::Engine => "/keystone_ui_colors"
38
+ ```
39
+
40
+ **2. Include the concern** in your `ApplicationController`:
41
+
42
+ ```ruby
43
+ class ApplicationController < ActionController::Base
44
+ include KeystoneUi::Colors::CurrentPalette
45
+ before_action :set_current_palette
46
+ end
47
+ ```
48
+
49
+ **3. Add the style tag** to your layout (`<head>`):
50
+
51
+ ```erb
52
+ <%= keystone_palette_style_tag %>
53
+ ```
54
+
55
+ This outputs CSS variables:
56
+
57
+ ```css
58
+ :root {
59
+ --color-accent-50: #eff6ff;
60
+ --color-accent-100: #dbeafe;
61
+ /* ... through 950 */
62
+ --color-surface-50: #f8fafc;
63
+ /* ... through 950 */
64
+ }
65
+ ```
66
+
67
+ **4. Register the Stimulus controller** in `app/javascript/controllers/index.js`:
68
+
69
+ ```js
70
+ import ThemeSettingsController from "./keystone_ui/colors/theme_settings_controller"
71
+ application.register("keystone-ui--colors--theme-settings", ThemeSettingsController)
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ Create `config/initializers/keystone_ui_colors.rb`:
77
+
78
+ ```ruby
79
+ KeystoneUi::Colors.configure do |config|
80
+ config.owner_class_name = "User" # Model that owns preferences
81
+ config.current_owner_method = :current_user # Controller method for current user
82
+ config.default_template = :ocean # Fallback theme
83
+ config.default_accent = "blue" # Fallback accent color
84
+ config.default_surface = "zinc" # Fallback surface color
85
+ config.default_mode = "light" # Fallback theme mode: "light", "dark" or "system"
86
+ config.layout = "application" # Layout for settings page
87
+ end
88
+ ```
89
+
90
+ All values shown are defaults and can be omitted.
91
+
92
+ ## Light and Dark Mode
93
+
94
+ Users choose Light, Dark or System on the settings page, and the choice is saved
95
+ with their palette. keystone_ui renders each page in, strongest first:
96
+
97
+ 1. The choice made with keystone_ui's `ui_theme_toggle` in this browser.
98
+ 2. The mode the signed-in user saved.
99
+ 3. `config.default_mode`, for users who saved none and for visitors who are not signed in.
100
+
101
+ Saving a mode on the settings page clears the toggle's choice in that browser, so the
102
+ saved mode takes effect. Pages are marked through keystone_ui's
103
+ `keystone_theme_attributes` helper on the layout's `html` tag.
104
+
105
+ ## Preset Themes
106
+
107
+ | Name | Accent | Surface | Description |
108
+ |----------|---------|---------|--------------------------------|
109
+ | Default | *(configured)* | *(configured)* | Uses config defaults |
110
+ | Ocean | blue | slate | Cool blues with slate undertones |
111
+ | Forest | emerald | stone | Natural greens with warm stone |
112
+ | Twilight | violet | zinc | Deep violet with clean zinc |
113
+ | Coral | rose | neutral | Warm rose with neutral balance |
114
+ | Arctic | cyan | gray | Bright cyan with crisp gray |
115
+
116
+ ## Available Colors
117
+
118
+ **Accents:** blue, emerald, cyan, indigo, violet, rose
119
+
120
+ **Surfaces:** zinc, slate, gray, neutral, stone
121
+
122
+ Each color includes shades 50 through 950 (Tailwind scale). Users can also pick arbitrary hex colors -- the gem generates a full shade palette automatically.
123
+
124
+ ## Custom Owner Models
125
+
126
+ The owner association is polymorphic:
127
+
128
+ ```ruby
129
+ KeystoneUi::Colors.configure do |config|
130
+ config.owner_class_name = "Account"
131
+ config.current_owner_method = :current_account
132
+ end
133
+ ```
134
+
135
+ ## Using the CSS Variables
136
+
137
+ ```css
138
+ .btn-primary {
139
+ background-color: var(--color-accent-500);
140
+ color: white;
141
+ }
142
+
143
+ .page-bg {
144
+ background-color: var(--color-surface-50);
145
+ }
146
+
147
+ .sidebar {
148
+ background-color: var(--color-surface-900);
149
+ color: var(--color-surface-100);
150
+ }
151
+ ```
152
+
153
+ ## Engine Routes
154
+
155
+ | Method | Path | Action |
156
+ |--------|------|--------|
157
+ | GET | / | Settings page |
158
+ | PATCH | / | Update preference |
159
+ | DELETE | / | Reset to default |
160
+
161
+ ## API Reference
162
+
163
+ ### `KeystoneUi::Colors::Palettes`
164
+
165
+ ```ruby
166
+ KeystoneUi::Colors::Palettes.accent(:blue) # => { 50 => "#eff6ff", ..., 950 => "#172554" }
167
+ KeystoneUi::Colors::Palettes.surface(:zinc) # => { 50 => "#fafafa", ..., 950 => "#09090b" }
168
+ KeystoneUi::Colors::Palettes.generate_shades("#8b5cf6") # => full shade palette from hex
169
+ ```
170
+
171
+ ### `KeystoneUi::Colors::Templates`
172
+
173
+ ```ruby
174
+ KeystoneUi::Colors::Templates.names # => [:default, :ocean, :forest, :twilight, :coral, :arctic]
175
+ KeystoneUi::Colors::Templates[:ocean] # => { accent: :blue, surface: :slate, label: "Ocean", ... }
176
+ KeystoneUi::Colors::Templates.all # => Hash of all templates
177
+ ```
178
+
179
+ ### `KeystoneUi::Colors::ThemePreference`
180
+
181
+ ```ruby
182
+ pref = KeystoneUi::Colors::ThemePreference.find_by(owner: current_user)
183
+ pref.apply_template!(:forest)
184
+ ```
185
+
186
+ Accepts named colors (`"blue"`) or hex values (`"#3b82f6"`) for `accent` and `surface`.
187
+
188
+ ## Updating
189
+
190
+ Run the update generator to get the latest Stimulus controller and any new migrations, then migrate:
191
+
192
+ ```bash
193
+ bin/rails generate keystone_ui:colors:update
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT License. See [MIT-LICENSE](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module KeystoneUi
6
+ module Colors
7
+ module CurrentPalette
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ helper_method :keystone_palette_css, :keystone_theme_mode if respond_to?(:helper_method)
12
+ end
13
+
14
+ def set_current_palette
15
+ owner = send(KeystoneUi::Colors.configuration.current_owner_method)
16
+
17
+ unless owner
18
+ @keystone_theme_mode = KeystoneUi::Colors.configuration.default_mode
19
+ build_palette_css(
20
+ KeystoneUi::Colors.configuration.default_accent,
21
+ KeystoneUi::Colors.configuration.default_surface
22
+ )
23
+ return
24
+ end
25
+
26
+ cached = session[:keystone_ui_colors_palette]
27
+
28
+ if cached && !stale_cache?(owner, cached)
29
+ @keystone_theme_mode = cached[:mode] || KeystoneUi::Colors.configuration.default_mode
30
+ build_palette_css(cached[:accent], cached[:surface])
31
+ return
32
+ end
33
+
34
+ preference = KeystoneUi::Colors::ThemePreference.find_by(owner: owner)
35
+ @keystone_theme_mode = preference&.mode || KeystoneUi::Colors.configuration.default_mode
36
+ accent = preference&.accent || KeystoneUi::Colors.configuration.default_accent
37
+ surface = preference&.surface || KeystoneUi::Colors.configuration.default_surface
38
+
39
+ build_palette_css(accent, surface)
40
+
41
+ if preference
42
+ session[:keystone_ui_colors_palette] = {
43
+ accent: preference.accent,
44
+ surface: preference.surface,
45
+ mode: preference.mode,
46
+ updated_at: preference.updated_at.to_i
47
+ }
48
+ end
49
+ end
50
+
51
+ def keystone_palette_css
52
+ @keystone_palette_css
53
+ end
54
+
55
+ def keystone_theme_mode
56
+ @keystone_theme_mode
57
+ end
58
+
59
+ private
60
+
61
+ def build_palette_css(accent, surface)
62
+ accent_shades = resolve_shades(accent, :accent)
63
+ surface_shades = resolve_shades(surface, :surface)
64
+
65
+ lines = []
66
+ accent_shades.each { |shade, hex| lines << " --color-accent-#{shade}: #{hex};" }
67
+ surface_shades.each { |shade, hex| lines << " --color-surface-#{shade}: #{hex};" }
68
+
69
+ @keystone_palette_css = ":root {\n#{lines.join("\n")}\n}"
70
+ end
71
+
72
+ def resolve_shades(value, type)
73
+ if value&.start_with?("#")
74
+ KeystoneUi::Colors::Palettes.generate_shades(value)
75
+ else
76
+ (type == :accent) ? KeystoneUi::Colors::Palettes.accent(value) : KeystoneUi::Colors::Palettes.surface(value)
77
+ end
78
+ end
79
+
80
+ def stale_cache?(owner, cached)
81
+ updated_at = KeystoneUi::Colors::ThemePreference
82
+ .where(owner: owner)
83
+ .pick(:updated_at)
84
+
85
+ return true unless updated_at
86
+
87
+ updated_at.to_i != cached[:updated_at]
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ class ApplicationController < ::ApplicationController
6
+ before_action { send(KeystoneUi::Colors.configuration.authentication_method) }
7
+ helper KeystoneUiHelper
8
+
9
+ layout -> { KeystoneUi::Colors.configuration.layout }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ class SettingsController < ApplicationController
6
+ def show
7
+ @preference = theme_preference
8
+ end
9
+
10
+ def destroy
11
+ ThemePreference.find_by(owner: current_owner)&.destroy
12
+ redirect_to keystone_ui_colors.settings_path, notice: "Color settings reset to default."
13
+ end
14
+
15
+ def update
16
+ @preference = theme_preference
17
+
18
+ if preference_params[:template_name].present?
19
+ template = Templates[preference_params[:template_name]]
20
+ @preference.assign_attributes(
21
+ accent: template[:accent].to_s,
22
+ surface: template[:surface].to_s,
23
+ template_name: preference_params[:template_name],
24
+ mode: preference_params[:mode]
25
+ )
26
+ else
27
+ @preference.assign_attributes(preference_params)
28
+ end
29
+
30
+ if @preference.save
31
+ cookies.delete(KeystoneUi::ThemeChoice::COOKIE)
32
+ redirect_to keystone_ui_colors.settings_path, notice: "Color settings updated."
33
+ else
34
+ render :show, status: :unprocessable_entity
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def current_owner
41
+ send(KeystoneUi::Colors.configuration.current_owner_method)
42
+ end
43
+
44
+ def theme_preference
45
+ ThemePreference.find_or_initialize_by(owner: current_owner)
46
+ end
47
+
48
+ def preference_params
49
+ params.require(:theme_preference).permit(:accent, :surface, :template_name, :mode)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ module PaletteHelper
6
+ def keystone_palette_style_tag
7
+ css = keystone_palette_css
8
+ return unless css
9
+
10
+ content_tag(:style, css)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,50 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["accentPicker", "surfacePicker", "customRadio", "templateLabel"]
5
+
6
+ connect() {
7
+ this.accentPickerTarget.addEventListener("change", () => this.customColorChanged())
8
+ this.surfacePickerTarget.addEventListener("change", () => this.customColorChanged())
9
+ }
10
+
11
+ selectTemplate(event) {
12
+ const input = event.currentTarget
13
+ const label = input.closest("[data-template]")
14
+ const accentHex = input.dataset.accentHex
15
+ const surfaceHex = input.dataset.surfaceHex
16
+
17
+ this.setPickerValue(this.accentPickerTarget, accentHex)
18
+ this.setPickerValue(this.surfacePickerTarget, surfaceHex)
19
+
20
+ this.highlightTemplate(label.dataset.template)
21
+ }
22
+
23
+ customColorChanged() {
24
+ if (this.hasCustomRadioTarget) this.customRadioTarget.checked = true
25
+ this.highlightTemplate("custom")
26
+ }
27
+
28
+ highlightTemplate(selected) {
29
+ this.templateLabelTargets.forEach(label => {
30
+ const border = label.querySelector("span")
31
+ if (label.dataset.template === selected) {
32
+ border.classList.remove("border-transparent")
33
+ border.classList.add("border-accent-500")
34
+ } else {
35
+ border.classList.remove("border-accent-500")
36
+ border.classList.add("border-transparent")
37
+ }
38
+ })
39
+ }
40
+
41
+ setPickerValue(wrapper, hex) {
42
+ const input = wrapper.querySelector("input[type='hidden']")
43
+ const swatch = wrapper.querySelector("[data-color-picker-target='swatch']")
44
+ const hexLabel = wrapper.querySelector("[data-color-picker-target='hexLabel']")
45
+
46
+ if (input) input.value = hex
47
+ if (swatch) swatch.style.backgroundColor = hex
48
+ if (hexLabel) hexLabel.textContent = hex
49
+ }
50
+ }
@@ -0,0 +1,7 @@
1
+ module KeystoneUi
2
+ module Colors
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ class ThemePreference < ApplicationRecord
6
+ self.table_name = "keystone_ui_colors_theme_preferences"
7
+
8
+ belongs_to :owner, polymorphic: true
9
+
10
+ SUPPORTED_ACCENTS = %w[blue emerald cyan indigo violet rose].freeze
11
+
12
+ SUPPORTED_SURFACES = %w[zinc slate gray neutral stone].freeze
13
+
14
+ HEX_COLOR = /\A#[0-9a-fA-F]{6}\z/
15
+
16
+ MODES = %w[light dark system].freeze
17
+
18
+ validate :accent_is_valid
19
+ validate :surface_is_valid
20
+ validates :template_name, inclusion: { in: Templates.names.map(&:to_s) }, allow_blank: true
21
+ validates :mode, inclusion: { in: MODES }, allow_nil: true
22
+
23
+ def apply_template!(name)
24
+ template = Templates[name]
25
+ update!(
26
+ accent: template[:accent].to_s,
27
+ surface: template[:surface].to_s,
28
+ template_name: name.to_s
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def accent_is_valid
35
+ return if SUPPORTED_ACCENTS.include?(accent) || accent&.match?(HEX_COLOR)
36
+
37
+ errors.add(:accent, "must be a supported color name or hex value")
38
+ end
39
+
40
+ def surface_is_valid
41
+ return if SUPPORTED_SURFACES.include?(surface) || surface&.match?(HEX_COLOR)
42
+
43
+ errors.add(:surface, "must be a supported color name or hex value")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,99 @@
1
+ <%= ui_page_header(title: "Color Settings") %>
2
+
3
+ <% if flash[:notice] %>
4
+ <%= ui_alert(message: flash[:notice], type: :success) %>
5
+ <% end %>
6
+
7
+ <%
8
+ current_accent_hex = if @preference.accent&.start_with?("#")
9
+ @preference.accent
10
+ else
11
+ KeystoneUi::Colors::Palettes.accent(@preference.accent || "blue")[500]
12
+ end
13
+
14
+ current_surface_hex = if @preference.surface&.start_with?("#")
15
+ @preference.surface
16
+ else
17
+ KeystoneUi::Colors::Palettes.surface(@preference.surface || "zinc")[500]
18
+ end
19
+
20
+ selected_template = @preference.template_name
21
+ custom_selected = @preference.accent&.start_with?("#") || (selected_template.blank? && @preference.persisted?)
22
+ %>
23
+
24
+ <%= ui_panel(padding: :lg) do %>
25
+ <%= form_with(
26
+ url: keystone_ui_colors.settings_path,
27
+ method: :patch,
28
+ scope: :theme_preference,
29
+ data: { turbo: false, controller: "keystone-ui--colors--theme-settings", selected_template: selected_template }
30
+ ) do |form| %>
31
+ <div style="display: flex; flex-direction: column; gap: 2rem;">
32
+ <%= ui_section(title: "Theme", spacing: :sm) do %>
33
+ <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
34
+ <% KeystoneUi::Colors::Templates.names.each do |name| %>
35
+ <% template = KeystoneUi::Colors::Templates[name] %>
36
+ <% accent_hex = KeystoneUi::Colors::Palettes.accent(template[:accent])[500] %>
37
+ <% surface_hex = KeystoneUi::Colors::Palettes.surface(template[:surface])[500] %>
38
+ <%= ui_option_card(
39
+ name: "theme_preference[template_name]",
40
+ value: name,
41
+ selected: selected_template == name.to_s,
42
+ input_data: {
43
+ action: "keystone-ui--colors--theme-settings#selectTemplate",
44
+ "accent-hex": accent_hex,
45
+ "surface-hex": surface_hex
46
+ },
47
+ label_data: {
48
+ "keystone-ui--colors--theme-settings-target": "templateLabel",
49
+ template: name
50
+ }
51
+ ) do %>
52
+ <span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 9999px; background-color: <%= accent_hex %>;"></span>
53
+ <span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 0.25rem; background-color: <%= surface_hex %>;"></span>
54
+ <span><%= template[:label] %></span>
55
+ <% end %>
56
+ <% end %>
57
+ <%= ui_option_card(
58
+ name: "theme_preference[template_name]",
59
+ value: "",
60
+ selected: custom_selected,
61
+ input_data: {
62
+ "keystone-ui--colors--theme-settings-target": "customRadio"
63
+ },
64
+ label_data: {
65
+ "keystone-ui--colors--theme-settings-target": "templateLabel",
66
+ template: "custom"
67
+ }
68
+ ) do %>
69
+ <span>Custom</span>
70
+ <% end %>
71
+ </div>
72
+ <% end %>
73
+
74
+ <%= ui_section(title: "Accent Color", spacing: :sm) do %>
75
+ <div data-keystone-ui--colors--theme-settings-target="accentPicker">
76
+ <%= ui_color_picker(name: "theme_preference[accent]", value: current_accent_hex) %>
77
+ </div>
78
+ <% end %>
79
+
80
+ <%= ui_section(title: "Surface", spacing: :sm) do %>
81
+ <div data-keystone-ui--colors--theme-settings-target="surfacePicker">
82
+ <%= ui_color_picker(name: "theme_preference[surface]", value: current_surface_hex) %>
83
+ </div>
84
+ <% end %>
85
+
86
+ <%= ui_section(title: "Mode", spacing: :sm) do %>
87
+ <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
88
+ <% KeystoneUi::Colors::ThemePreference::MODES.each do |mode| %>
89
+ <%= ui_radio_card(name: "theme_preference[mode]", value: mode, label: mode.capitalize, checked: (@preference.mode || KeystoneUi::Colors.configuration.default_mode) == mode) %>
90
+ <% end %>
91
+ </div>
92
+ <% end %>
93
+
94
+ <div>
95
+ <%= ui_button(label: "Save", type: :submit) %>
96
+ </div>
97
+ </div>
98
+ <% end %>
99
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ KeystoneUi::Colors::Engine.routes.draw do
2
+ get "/", to: "settings#show", as: :settings
3
+ patch "/", to: "settings#update"
4
+ delete "/", to: "settings#destroy"
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module KeystoneUi
7
+ module Colors
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Installs KeystoneUi::Colors: copies migration and prints setup instructions."
15
+
16
+ def copy_migration
17
+ migration_template(
18
+ "create_keystone_ui_colors_theme_preferences.rb.erb",
19
+ "db/migrate/create_keystone_ui_colors_theme_preferences.rb"
20
+ )
21
+ end
22
+
23
+ def copy_stimulus_controller
24
+ js_source = File.expand_path("../../../../../app/javascript/keystone_ui/colors/theme_settings_controller.js", __dir__)
25
+ create_file "app/javascript/controllers/keystone_ui/colors/theme_settings_controller.js", File.read(js_source)
26
+ end
27
+
28
+ def show_instructions
29
+ say ""
30
+ say "KeystoneUi::Colors installed! Next steps:", :green
31
+ say ""
32
+ say " 1. Run migrations:"
33
+ say " rails db:migrate"
34
+ say ""
35
+ say " 2. Mount the engine in config/routes.rb:"
36
+ say " mount KeystoneUi::Colors::Engine => '/keystone_ui_colors'"
37
+ say ""
38
+ say " 3. Include the concern in your ApplicationController:"
39
+ say " include KeystoneUi::Colors::CurrentPalette"
40
+ say " before_action :set_current_palette"
41
+ say ""
42
+ say " 4. Add the style tag to your layout <head>:"
43
+ say " <%= keystone_palette_style_tag %>"
44
+ say ""
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,15 @@
1
+ class CreateKeystoneUiColorsThemePreferences < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :keystone_ui_colors_theme_preferences do |t|
4
+ t.string :accent, null: false
5
+ t.string :surface, null: false
6
+ t.string :template_name
7
+ t.string :mode
8
+ t.references :owner, polymorphic: true, null: false
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :keystone_ui_colors_theme_preferences, %i[owner_type owner_id], unique: true,
13
+ name: "index_keystone_ui_colors_theme_prefs_on_owner"
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ class AddModeToKeystoneUiColorsThemePreferences < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :keystone_ui_colors_theme_preferences, :mode, :string
4
+ end
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module KeystoneUi
7
+ module Colors
8
+ module Generators
9
+ class UpdateGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+
12
+ source_root File.expand_path("../../../../../app/javascript/keystone_ui/colors", __dir__)
13
+
14
+ desc "Updates KeystoneUi::Colors: copies the Stimulus controller and adds new migrations."
15
+
16
+ def add_mode_migration
17
+ migration_template(
18
+ File.expand_path("templates/add_mode_to_keystone_ui_colors_theme_preferences.rb.erb", __dir__),
19
+ "db/migrate/add_mode_to_keystone_ui_colors_theme_preferences.rb"
20
+ )
21
+ end
22
+
23
+ def copy_stimulus_controller
24
+ copy_file "theme_settings_controller.js",
25
+ "app/javascript/controllers/keystone_ui/colors/theme_settings_controller.js"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ class Configuration
6
+ attr_accessor :current_owner_method, :authentication_method, :default_template, :default_accent, :default_surface, :default_mode, :layout
7
+
8
+ def initialize
9
+ @current_owner_method = :current_user
10
+ @authentication_method = :authenticate_user!
11
+ @default_template = :ocean
12
+ @default_accent = "blue"
13
+ @default_surface = "zinc"
14
+ @default_mode = "light"
15
+ @layout = "application"
16
+ end
17
+ end
18
+
19
+ def self.configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def self.configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def self.reset_configuration!
28
+ @configuration = Configuration.new
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace KeystoneUi::Colors
7
+
8
+ initializer "keystone_ui.colors.theme_mode" do
9
+ KeystoneUi.configure do |config|
10
+ config.theme_mode_supplier = lambda do |view|
11
+ view.respond_to?(:keystone_theme_mode) ? view.keystone_theme_mode : KeystoneUi::Colors.configuration.default_mode
12
+ end
13
+ end
14
+ end
15
+
16
+ initializer "keystone_ui.colors.url_helpers" do
17
+ ActiveSupport.on_load(:action_controller) do
18
+ helper Rails.application.routes.url_helpers
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ module Palettes
6
+ ACCENTS = {
7
+ blue: { 50 => "#eff6ff", 100 => "#dbeafe", 200 => "#bfdbfe", 300 => "#93c5fd", 400 => "#60a5fa", 500 => "#3b82f6", 600 => "#2563eb", 700 => "#1d4ed8", 800 => "#1e40af", 900 => "#1e3a8a", 950 => "#172554" },
8
+ emerald: { 50 => "#ecfdf5", 100 => "#d1fae5", 200 => "#a7f3d0", 300 => "#6ee7b7", 400 => "#34d399", 500 => "#10b981", 600 => "#059669", 700 => "#047857", 800 => "#065f46", 900 => "#064e3b", 950 => "#022c22" },
9
+ cyan: { 50 => "#ecfeff", 100 => "#cffafe", 200 => "#a5f3fc", 300 => "#67e8f9", 400 => "#22d3ee", 500 => "#06b6d4", 600 => "#0891b2", 700 => "#0e7490", 800 => "#155e75", 900 => "#164e63", 950 => "#083344" },
10
+ indigo: { 50 => "#eef2ff", 100 => "#e0e7ff", 200 => "#c7d2fe", 300 => "#a5b4fc", 400 => "#818cf8", 500 => "#6366f1", 600 => "#4f46e5", 700 => "#4338ca", 800 => "#3730a3", 900 => "#312e81", 950 => "#1e1b4b" },
11
+ violet: { 50 => "#f5f3ff", 100 => "#ede9fe", 200 => "#ddd6fe", 300 => "#c4b5fd", 400 => "#a78bfa", 500 => "#8b5cf6", 600 => "#7c3aed", 700 => "#6d28d9", 800 => "#5b21b6", 900 => "#4c1d95", 950 => "#2e1065" },
12
+ rose: { 50 => "#fff1f2", 100 => "#ffe4e6", 200 => "#fecdd3", 300 => "#fda4af", 400 => "#fb7185", 500 => "#f43f5e", 600 => "#e11d48", 700 => "#be123c", 800 => "#9f1239", 900 => "#881337", 950 => "#4c0519" }
13
+ }.freeze
14
+
15
+ SURFACES = {
16
+ zinc: { 50 => "#fafafa", 100 => "#f4f4f5", 200 => "#e4e4e7", 300 => "#d4d4d8", 400 => "#a1a1aa", 500 => "#71717a", 600 => "#52525b", 700 => "#3f3f46", 800 => "#27272a", 900 => "#18181b", 950 => "#09090b" },
17
+ slate: { 50 => "#f8fafc", 100 => "#f1f5f9", 200 => "#e2e8f0", 300 => "#cbd5e1", 400 => "#94a3b8", 500 => "#64748b", 600 => "#475569", 700 => "#334155", 800 => "#1e293b", 900 => "#0f172a", 950 => "#020617" },
18
+ gray: { 50 => "#f9fafb", 100 => "#f3f4f6", 200 => "#e5e7eb", 300 => "#d1d5db", 400 => "#9ca3af", 500 => "#6b7280", 600 => "#4b5563", 700 => "#374151", 800 => "#1f2937", 900 => "#111827", 950 => "#030712" },
19
+ neutral: { 50 => "#fafafa", 100 => "#f5f5f5", 200 => "#e5e5e5", 300 => "#d4d4d4", 400 => "#a3a3a3", 500 => "#737373", 600 => "#525252", 700 => "#404040", 800 => "#262626", 900 => "#171717", 950 => "#0a0a0a" },
20
+ stone: { 50 => "#fafaf9", 100 => "#f5f5f4", 200 => "#e7e5e4", 300 => "#d6d3d1", 400 => "#a8a29e", 500 => "#78716c", 600 => "#57534e", 700 => "#44403c", 800 => "#292524", 900 => "#1c1917", 950 => "#0c0a09" }
21
+ }.freeze
22
+
23
+ # Shade lightness targets (0.0 = black, 1.0 = white)
24
+ SHADE_TARGETS = {
25
+ 50 => 0.95, 100 => 0.9, 200 => 0.8, 300 => 0.7, 400 => 0.6,
26
+ 500 => 0.0, 600 => -0.15, 700 => -0.3, 800 => -0.45, 900 => -0.6, 950 => -0.75
27
+ }.freeze
28
+
29
+ def self.accent(name)
30
+ ACCENTS.fetch(name.to_sym)
31
+ end
32
+
33
+ def self.surface(name)
34
+ SURFACES.fetch(name.to_sym)
35
+ end
36
+
37
+ def self.generate_shades(hex)
38
+ r, g, b = hex_to_rgb(hex)
39
+
40
+ SHADE_TARGETS.each_with_object({}) do |(shade, factor), result|
41
+ result[shade] = if shade == 500
42
+ hex
43
+ elsif factor > 0
44
+ rgb_to_hex(mix(r, g, b, 255, 255, 255, factor))
45
+ else
46
+ rgb_to_hex(mix(r, g, b, 0, 0, 0, factor.abs))
47
+ end
48
+ end
49
+ end
50
+
51
+ def self.hex_to_rgb(hex)
52
+ hex = hex.delete_prefix("#")
53
+ [ hex[0..1], hex[2..3], hex[4..5] ].map { |c| c.to_i(16) }
54
+ end
55
+ private_class_method :hex_to_rgb
56
+
57
+ def self.rgb_to_hex(rgb)
58
+ "#" + rgb.map { |c| c.clamp(0, 255).round.to_s(16).rjust(2, "0") }.join
59
+ end
60
+ private_class_method :rgb_to_hex
61
+
62
+ def self.mix(r1, g1, b1, r2, g2, b2, weight)
63
+ [
64
+ r1 + (r2 - r1) * weight,
65
+ g1 + (g2 - g1) * weight,
66
+ b1 + (b2 - b1) * weight
67
+ ]
68
+ end
69
+ private_class_method :mix
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ module Templates
6
+ PRESETS = {
7
+ ocean: {
8
+ accent: :blue,
9
+ surface: :slate,
10
+ label: "Ocean",
11
+ description: "Cool blues with slate undertones"
12
+ },
13
+ forest: {
14
+ accent: :emerald,
15
+ surface: :stone,
16
+ label: "Forest",
17
+ description: "Natural greens with warm stone"
18
+ },
19
+ twilight: {
20
+ accent: :violet,
21
+ surface: :zinc,
22
+ label: "Twilight",
23
+ description: "Deep violet with clean zinc"
24
+ },
25
+ coral: {
26
+ accent: :rose,
27
+ surface: :neutral,
28
+ label: "Coral",
29
+ description: "Warm rose with neutral balance"
30
+ },
31
+ arctic: {
32
+ accent: :cyan,
33
+ surface: :gray,
34
+ label: "Arctic",
35
+ description: "Bright cyan with crisp gray"
36
+ }
37
+ }.freeze
38
+
39
+ def self.all
40
+ PRESETS.merge(default: default_template)
41
+ end
42
+
43
+ def self.[](name)
44
+ return default_template if name.to_sym == :default
45
+
46
+ PRESETS.fetch(name.to_sym)
47
+ end
48
+
49
+ def self.names
50
+ [ :default ] + PRESETS.keys
51
+ end
52
+
53
+ def self.default_template
54
+ {
55
+ accent: KeystoneUi::Colors.configuration.default_accent,
56
+ surface: KeystoneUi::Colors.configuration.default_surface,
57
+ label: "Default",
58
+ description: "Default theme"
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Colors
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ require "keystone_ui"
2
+ require "keystone_ui/colors/version"
3
+ require "keystone_ui/colors/configuration"
4
+ require "keystone_ui/colors/templates"
5
+ require "keystone_ui/colors/palettes"
6
+ require "keystone_ui/colors/engine"
7
+
8
+ module KeystoneUi
9
+ module Colors
10
+ end
11
+ end
@@ -0,0 +1 @@
1
+ require "keystone_ui/colors"
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: keystone_ui-colors
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Tyler Schneider
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: keystone_ui
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.10.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.10.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activesupport
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '7.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ description: A companion engine for keystone_ui that provides preset themes and per-user
70
+ color palette persistence.
71
+ email:
72
+ - tylercschneider@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - MIT-LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - app/controllers/concerns/keystone_ui/colors/current_palette.rb
81
+ - app/controllers/keystone_ui/colors/application_controller.rb
82
+ - app/controllers/keystone_ui/colors/settings_controller.rb
83
+ - app/helpers/keystone_ui/colors/palette_helper.rb
84
+ - app/javascript/keystone_ui/colors/theme_settings_controller.js
85
+ - app/models/keystone_ui/colors/application_record.rb
86
+ - app/models/keystone_ui/colors/theme_preference.rb
87
+ - app/views/keystone_ui/colors/settings/show.html.erb
88
+ - config/routes.rb
89
+ - lib/generators/keystone_ui/colors/install/install_generator.rb
90
+ - lib/generators/keystone_ui/colors/install/templates/create_keystone_ui_colors_theme_preferences.rb.erb
91
+ - lib/generators/keystone_ui/colors/update/templates/add_mode_to_keystone_ui_colors_theme_preferences.rb.erb
92
+ - lib/generators/keystone_ui/colors/update/update_generator.rb
93
+ - lib/keystone_ui-colors.rb
94
+ - lib/keystone_ui/colors.rb
95
+ - lib/keystone_ui/colors/configuration.rb
96
+ - lib/keystone_ui/colors/engine.rb
97
+ - lib/keystone_ui/colors/palettes.rb
98
+ - lib/keystone_ui/colors/templates.rb
99
+ - lib/keystone_ui/colors/version.rb
100
+ homepage: https://github.com/DYB-Development/keystone_ui-colors
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ homepage_uri: https://github.com/DYB-Development/keystone_ui-colors
105
+ source_code_uri: https://github.com/DYB-Development/keystone_ui-colors
106
+ changelog_uri: https://github.com/DYB-Development/keystone_ui-colors/blob/main/CHANGELOG.md
107
+ bug_tracker_uri: https://github.com/DYB-Development/keystone_ui-colors/issues
108
+ documentation_uri: https://github.com/DYB-Development/keystone_ui-colors#readme
109
+ rubygems_mfa_required: 'true'
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: 3.1.0
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.5.16
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: Per-user color palette settings for Keystone UI.
129
+ test_files: []