A Rails engine that adds per-user color palette persistence and preset themes. Companion to keystone_ui.
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.
- Ruby >= 3.1
- Rails >= 7.0
- keystone_ui >= 0.18.0
Add to your Gemfile:
gem "keystone_ui-colors"Run the install generator:
bin/rails generate keystone_ui:colors:install
bin/rails db:migrateThis creates:
- A migration for
keystone_ui_colors_theme_preferences - A Stimulus controller at
app/javascript/controllers/keystone_ui/colors/theme_settings_controller.js
1. Mount the engine in config/routes.rb:
mount KeystoneUi::Colors::Engine => "/keystone_ui_colors"2. Include the concern in your ApplicationController:
class ApplicationController < ActionController::Base
include KeystoneUi::Colors::CurrentPalette
before_action :set_current_palette
end3. Add the style tag to your layout (<head>):
<%= keystone_palette_style_tag %>This outputs CSS variables:
:root {
--color-accent-50: #eff6ff;
--color-accent-100: #dbeafe;
/* ... through 950 */
--color-surface-50: #f8fafc;
/* ... through 950 */
}4. Register the Stimulus controller in app/javascript/controllers/index.js:
import ThemeSettingsController from "./keystone_ui/colors/theme_settings_controller"
application.register("keystone-ui--colors--theme-settings", ThemeSettingsController)Create config/initializers/keystone_ui_colors.rb:
KeystoneUi::Colors.configure do |config|
config.owner_class_name = "User" # Model that owns preferences
config.current_owner_method = :current_user # Controller method for current user
config.default_template = :ocean # Fallback theme
config.default_accent = "blue" # Fallback accent color
config.default_surface = "zinc" # Fallback surface color
config.default_mode = "light" # Fallback theme mode: "light", "dark", "system" or "custom"
config.default_background = "#ffffff" # Custom mode background when nothing else sets one
config.default_text = "#18181b" # Custom mode text colour when nothing else sets one
config.account_colors = true # Let account owners choose their account's colours
config.current_account_method = nil # Controller method for the current account, such as :current_account
config.layout = "application" # Layout for settings page
endAll values shown are defaults and can be omitted.
Users choose Light, Dark, System or Custom on the settings page, and the choice is saved with their palette. keystone_ui renders each page in, strongest first:
- The choice made with keystone_ui's
ui_theme_togglein this browser. - The mode the signed-in user saved.
config.default_mode, for users who saved none and for visitors who are not signed in.
Saving a mode on the settings page clears the toggle's choice in that browser, so the
saved mode takes effect. Pages are marked through keystone_ui's
keystone_theme_attributes helper on the layout's html tag.
A page in Custom mode is drawn from a background colour and a text colour, which
the gem writes as --color-custom-background and --color-custom-text with the
rest of the palette. keystone_ui-styles draws white in the background and every
gray and zinc shade as a blend of the text into it.
- A preset theme supplies both colours.
- With custom colours, the surface colour the user picks is the background and the Text Color picker sets the text.
- Anything not set falls back to
config.default_backgroundandconfig.default_text.
Colours are chosen at up to three levels, and a page uses the first that applies:
- The signed-in user's own colours, when the app lets accounts choose and the user's account lets its members choose.
- The account's colours, when the app lets accounts choose.
- The app's configured defaults, which fall back to keystone_ui's own colours.
An account's colours are a ThemePreference owned by the account. Its
members_choose column, true by default, decides whether members may use their
own colours. Set config.current_account_method to the controller method that
returns the current account. With none set, the account level is skipped.
Light, Dark and System stay each user's own choice at every level. Custom is offered to a user who may choose their own colours, and to anyone whose applying colours draw a background other than white.
Two partials place the pickers in an app's own settings, each taking person:,
account: and submit_url::
keystone_ui/colors/settings/pickersaves throughKeystoneUi::Colors::PickColours. A user who may not choose colours sees only the mode, and a save from them keeps only the mode.keystone_ui/colors/settings/account_pickersaves throughKeystoneUi::Colors::PickAccountColours, and holds the account's colours and its Members switch.
Who may set an account's colours is for the app to decide. With settings_hub, register the account picker in the account area behind a capability:
SettingsHub.section :account_appearance, area: :account, title: "Appearance",
capability: :manage_account,
renders: "keystone_ui/colors/settings/account_picker",
runs: "KeystoneUi::Colors::PickAccountColours"A controller that declares keystone_host_colors renders its pages in the host's
configured accent, surface, custom colours and mode, whatever the signed-in user
saved. It takes the same only: and except: options as before_action.
class HomeController < ApplicationController
keystone_host_colors
endA choice made with keystone_ui's ui_theme_toggle in that browser still sets
light or dark on these pages.
Such a page also shows the host's default look, over any look a user or account chose, for the same actions.
When the host registers looks with keystone_ui, a signed-in user's preference can
hold one in its look column, and every page they see carries it as
data-look on the html tag. A user with no saved look, and a visitor who is
not signed in, see the host's default_look. A saved look the host no longer registers is passed over for the account's look,
or for the host's default when the account's look is not registered either. The
settings page then shows no look selected and still saves. While an account's colours apply, the
page still shows the user's own look.
The colours settings page lists the app's own look first and then every
registered look, with the user's current look chosen, and saves the one they
pick. The app's own look is labelled "Classic" unless the app sets
app_look_label, is chosen when no look is saved, and saving it clears the saved
look, so the page goes back to the account's look or the app's default. A name the host does not register is
refused with a message and nothing is stored. With no looks registered the page
shows no look choice.
A host that wants every user on one look sets user_looks to false:
KeystoneUi::Colors.configure do |config|
config.user_looks = false
endThe settings page then shows no look choice, a save ignores any look sent with
it and keeps the rest, and every page shows the host's default look, even for a
user who saved one earlier. user_looks is true by default.
An account admin picks the account's look on the account picker, which lists
every registered look with the account's current one chosen. A member with no
look of their own sees the account's look, and a member who chose one keeps it.
A host that keeps accounts off looks sets account_looks to false, and the
account picker then shows no look choice and a saved account look is not
applied. account_looks is true by default.
The account picker also has a switch for whether members choose their own look, on by default and separate from the switch for colours. With it off, every member sees the account's look whatever they saved, and a member's picker shows no look choice. Run the update generator to add its column to an app that installed an earlier version.
| Name | Accent | Surface | Custom background | Custom text | Description |
|---|---|---|---|---|---|
| Default | (configured) | (configured) | (configured) | (configured) | Uses config defaults |
| Ocean | blue | slate | #e0f2fe |
#0c4a6e |
Cool blues with slate undertones |
| Forest | emerald | stone | #ecfdf5 |
#064e3b |
Natural greens with warm stone |
| Twilight | violet | zinc | #1e1b4b |
#ede9fe |
Deep violet with clean zinc |
| Coral | rose | neutral | #fff1f2 |
#4c0519 |
Warm rose with neutral balance |
| Arctic | cyan | gray | #ecfeff |
#164e63 |
Bright cyan with crisp gray |
Accents: blue, emerald, cyan, indigo, violet, rose
Surfaces: zinc, slate, gray, neutral, stone
Each color includes shades 50 through 950 (Tailwind scale). Users can also pick arbitrary hex colors -- the gem generates a full shade palette automatically.
The owner association is polymorphic:
KeystoneUi::Colors.configure do |config|
config.owner_class_name = "Account"
config.current_owner_method = :current_account
end.btn-primary {
background-color: var(--color-accent-500);
color: white;
}
.page-bg {
background-color: var(--color-surface-50);
}
.sidebar {
background-color: var(--color-surface-900);
color: var(--color-surface-100);
}| Method | Path | Action |
|---|---|---|
| GET | / | Settings page |
| PATCH | / | Update preference |
| DELETE | / | Reset to default |
KeystoneUi::Colors::Palettes.accent(:blue) # => { 50 => "#eff6ff", ..., 950 => "#172554" }
KeystoneUi::Colors::Palettes.surface(:zinc) # => { 50 => "#fafafa", ..., 950 => "#09090b" }
KeystoneUi::Colors::Palettes.generate_shades("#8b5cf6") # => full shade palette from hexKeystoneUi::Colors::Templates.names # => [:default, :ocean, :forest, :twilight, :coral, :arctic]
KeystoneUi::Colors::Templates[:ocean] # => { accent: :blue, surface: :slate, label: "Ocean", ... }
KeystoneUi::Colors::Templates.all # => Hash of all templatespref = KeystoneUi::Colors::ThemePreference.find_by(owner: current_user)
pref.apply_template!(:forest)Accepts named colors ("blue") or hex values ("#3b82f6") for accent and surface.
Run the update generator to get the latest Stimulus controller and any new migrations (such as the text colour and look columns), then migrate:
bin/rails generate keystone_ui:colors:updateMIT License. See MIT-LICENSE.