keystone_ui 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3461e5f2648165cab0fbea2244fe15dbf42c2bf1da5a96f5ebfdfe89b7a2278c
4
- data.tar.gz: c39d95798511ddbe58a61144a98fca8b2070475c3850e83b7016a73627c439ce
3
+ metadata.gz: 9007e4cf813a277f78f01bdf10df84d28856a5eaf1e7460c51e77db5d8b13143
4
+ data.tar.gz: a31771c5217f422c78000dc895be2314e9ecac0de206155f3d3a123b3da362af
5
5
  SHA512:
6
- metadata.gz: 2a0eb208c6a668bf2d2e62ae1d5a996f04b4c8cae8b5f014287cc066a4840195b24756b7efe5a44ef1b3aa2cec367c32129361897f2aa8dbeff09ee786a8ba5f
7
- data.tar.gz: 5d596e9a799b189775af38d413afc945fea87828d90517cd21930c80a6efa9a222faa32d8401c4d8bfe8f4d5bc03f32f1e4bb8dfb5f8691aa570e8322d4340bd
6
+ metadata.gz: d5649e25777890bf3bd01be1f42260c92038b66cef09c71f1b6e4655658f6d602102d0adcef0bd4b063edb837490818df7734c84b3f0e0198e2a859a83b2ea10
7
+ data.tar.gz: 52bce90d7e68c9ead4e459f71376c16257b235b1418ef92c156bd8d7fb1bd2b33a17cf7111bcb0412d19d79e6ad0626ce8d2aa72db8fe00988fbaae41705151d
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tyler Schneider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -12,6 +12,7 @@ import AccordionController from "keystone_ui/accordion_controller"
12
12
  import StatCardInfoController from "keystone_ui/stat_card_info_controller"
13
13
  import AutoSubmitController from "keystone_ui/auto_submit_controller"
14
14
  import LineChartController from "keystone_ui/line_chart_controller"
15
+ import ThemeToggleController from "keystone_ui/theme_toggle_controller"
15
16
 
16
17
  export function registerControllers(application) {
17
18
  application.register("color-picker", ColorPickerController)
@@ -28,6 +29,7 @@ export function registerControllers(application) {
28
29
  application.register("stat-card-info", StatCardInfoController)
29
30
  application.register("auto-submit", AutoSubmitController)
30
31
  application.register("line-chart", LineChartController)
32
+ application.register("theme-toggle", ThemeToggleController)
31
33
  }
32
34
 
33
35
  export { ColorPickerController, MultiSelectController, SwipeDeckController, ColumnPickerController, FileUploadController, DropdownController, DismissController, ModalController, ClipboardController, TabSwitcherController, AccordionController, StatCardInfoController, AutoSubmitController, LineChartController }
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const COOKIE = "keystone_theme"
4
+ const ONE_YEAR = 60 * 60 * 24 * 365
5
+
6
+ export default class extends Controller {
7
+ choose({ params: { mode } }) {
8
+ const page = this.element.ownerDocument
9
+ this.#showPressed(mode)
10
+
11
+ if (mode === "system") {
12
+ delete page.documentElement.dataset.theme
13
+ page.cookie = `${COOKIE}=; path=/; max-age=0; samesite=lax`
14
+ } else {
15
+ page.documentElement.dataset.theme = mode
16
+ page.cookie = `${COOKIE}=${mode}; path=/; max-age=${ONE_YEAR}; samesite=lax`
17
+ }
18
+ }
19
+
20
+ #showPressed(mode) {
21
+ this.element.querySelectorAll("[data-theme-toggle-mode-param]").forEach((option) => {
22
+ option.setAttribute("aria-pressed", String(option.dataset.themeToggleModeParam === mode))
23
+ })
24
+ }
25
+ }
@@ -3,17 +3,17 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class ButtonComponent < ViewComponent::Base
6
- BASE_CLASSES = "inline-flex items-center justify-center font-semibold rounded-lg border-0 cursor-pointer no-underline"
6
+ BASE_CLASSES = "ks-button"
7
7
 
8
8
  VARIANT_CLASSES = {
9
- secondary: "bg-gray-500 text-white hover:bg-gray-400",
10
- danger: "bg-red-600 text-white hover:bg-red-500"
9
+ secondary: "ks-button-secondary",
10
+ danger: "ks-button-danger"
11
11
  }.freeze
12
12
 
13
13
  SIZE_CLASSES = {
14
- sm: "text-sm px-3 py-1.5",
15
- md: "text-base px-4 py-2",
16
- lg: "text-lg px-5 py-3"
14
+ sm: "ks-button-sm",
15
+ md: "ks-button-md",
16
+ lg: "ks-button-lg"
17
17
  }.freeze
18
18
 
19
19
  def initialize(label:, href: nil, variant: :primary, size: :md, type: :submit, data: nil)
@@ -27,7 +27,7 @@ module Keystone
27
27
 
28
28
  def classes
29
29
  variant_css = if @variant == :primary
30
- "bg-accent-600 text-white hover:bg-accent-500"
30
+ "ks-button-primary"
31
31
  else
32
32
  VARIANT_CLASSES.fetch(@variant)
33
33
  end
@@ -14,7 +14,7 @@
14
14
  <% elsif textarea? %>
15
15
  <textarea name="<%= @attribute %>" rows="3" class="<%= Keystone::Ui::TextareaComponent::BASE_CLASSES %>"<% if @placeholder %> placeholder="<%= @placeholder %>"<% end %>><%= @value %></textarea>
16
16
  <% elsif select? %>
17
- <select name="<%= @attribute %>" class="<%= Keystone::Ui::SelectComponent::BASE_CLASSES %> <%= Keystone::Ui::SelectComponent::FOCUS_CLASSES %>">
17
+ <select name="<%= @attribute %>" class="<%= Keystone::Ui::SelectComponent::BASE_CLASSES %>">
18
18
  <% unless required? %><option value=""></option><% end %>
19
19
  <% select_options.each do |label, value| %>
20
20
  <option value="<%= value %>" <%= "selected" if @value.to_s == value.to_s %>><%= label %></option>
@@ -4,11 +4,11 @@ module Keystone
4
4
  module Ui
5
5
  class FormFieldComponent < ViewComponent::Base
6
6
  WRAPPER_CLASSES = "space-y-1"
7
- LABEL_CLASSES = "block text-sm font-medium text-gray-700 dark:text-gray-300"
8
- REQUIRED_CLASSES = "text-red-500 ml-0.5"
9
- HINT_CLASSES = "mt-1 text-sm text-gray-500 dark:text-gray-400"
10
- ERROR_CLASSES = "mt-1 text-sm text-red-600 dark:text-red-400"
11
- CHECKBOX_CLASSES = "rounded border-gray-300 text-accent-600 focus:ring-accent-500 dark:border-zinc-600 dark:bg-zinc-900"
7
+ LABEL_CLASSES = "ks-label"
8
+ REQUIRED_CLASSES = "ks-required"
9
+ HINT_CLASSES = "ks-hint"
10
+ ERROR_CLASSES = "ks-error"
11
+ CHECKBOX_CLASSES = "ks-checkbox"
12
12
  CHECKBOX_WRAPPER_CLASSES = "flex items-center gap-2"
13
13
 
14
14
  def initialize(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [])
@@ -3,9 +3,9 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class InputComponent < ViewComponent::Base
6
- BASE_CLASSES = "block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-1 dark:bg-zinc-900 dark:border-zinc-700 dark:text-white dark:placeholder:text-gray-500"
6
+ BASE_CLASSES = "ks-input"
7
7
 
8
- DISABLED_CLASSES = "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-zinc-800 dark:text-gray-400"
8
+ DISABLED_CLASSES = "ks-input-disabled"
9
9
 
10
10
  TYPE_MAP = {
11
11
  text: "text",
@@ -26,10 +26,8 @@ module Keystone
26
26
  @step = step
27
27
  end
28
28
 
29
- FOCUS_CLASSES = "focus:border-accent-500 focus:ring-accent-500 dark:focus:border-accent-400 dark:focus:ring-accent-400"
30
-
31
29
  def classes
32
- tokens = [ BASE_CLASSES, FOCUS_CLASSES ]
30
+ tokens = [ BASE_CLASSES ]
33
31
  tokens << DISABLED_CLASSES if @disabled
34
32
  tokens.join(" ")
35
33
  end
@@ -12,7 +12,7 @@ module Keystone
12
12
  @shadow = shadow
13
13
  end
14
14
 
15
- BASE_CLASSES = "border border-gray-200 bg-white dark:bg-zinc-900 dark:border-zinc-700"
15
+ BASE_CLASSES = "ks-panel"
16
16
  SHADOW_CLASS = "shadow-sm"
17
17
 
18
18
  def classes
@@ -3,9 +3,9 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class SelectComponent < ViewComponent::Base
6
- BASE_CLASSES = "block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-1 dark:bg-zinc-900 dark:border-zinc-700 dark:text-white"
6
+ BASE_CLASSES = "ks-input"
7
7
 
8
- DISABLED_CLASSES = "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-zinc-800 dark:text-gray-400"
8
+ DISABLED_CLASSES = "ks-input-disabled"
9
9
 
10
10
  attr_reader :options, :selected, :include_blank
11
11
 
@@ -17,10 +17,8 @@ module Keystone
17
17
  @disabled = disabled
18
18
  end
19
19
 
20
- FOCUS_CLASSES = "focus:border-accent-500 focus:ring-accent-500 dark:focus:border-accent-400 dark:focus:ring-accent-400"
21
-
22
20
  def classes
23
- tokens = [ BASE_CLASSES, FOCUS_CLASSES ]
21
+ tokens = [ BASE_CLASSES ]
24
22
  tokens << DISABLED_CLASSES if @disabled
25
23
  tokens.join(" ")
26
24
  end
@@ -5,18 +5,16 @@
5
5
  <button type="button" class="<%= info_button_classes %>" data-action="click->stat-card-info#toggle" aria-label="Show metric details">
6
6
  <%= info_icon.html_safe %>
7
7
  </button>
8
+ <div class="<%= disclosure_classes %>" data-stat-card-info-target="panel">
9
+ <% if definition %><p><span class="font-medium text-gray-700 dark:text-gray-300">Captures:</span> <%= definition %></p><% end %>
10
+ <% if calculation %><p><span class="font-medium text-gray-700 dark:text-gray-300">Calc:</span> <%= calculation %></p><% end %>
11
+ </div>
8
12
  <% end %>
9
13
  </div>
10
14
  <p class="<%= value_classes %>">
11
- <%= value %><% if suffix? %><span class="<%= suffix_classes %>"> <%= suffix %></span><% end %>
15
+ <% if link? %><a href="<%= href %>" class="<%= value_link_classes %>"><%= value %></a><% else %><%= value %><% end %><% if suffix? %><span class="<%= suffix_classes %>"> <%= suffix %></span><% end %>
12
16
  </p>
13
17
  <% if change? %>
14
18
  <p class="mt-1 text-sm font-medium <%= change_classes %>"><%= change_label %></p>
15
19
  <% end %>
16
- <% if info? %>
17
- <div class="<%= disclosure_classes %>" data-stat-card-info-target="panel">
18
- <% if definition %><p><span class="font-medium text-gray-700 dark:text-gray-300">Captures:</span> <%= definition %></p><% end %>
19
- <% if calculation %><p><span class="font-medium text-gray-700 dark:text-gray-300">Calc:</span> <%= calculation %></p><% end %>
20
- </div>
21
- <% end %>
22
20
  </div>
@@ -3,12 +3,13 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class StatCardComponent < ViewComponent::Base
6
- CARD_CLASSES = "rounded-xl border border-gray-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800"
6
+ CARD_CLASSES = "relative rounded-xl border border-gray-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800"
7
7
  LABEL_CLASSES = "text-sm font-medium text-gray-500 dark:text-gray-400"
8
8
  VALUE_BASE_CLASSES = "mt-1 text-3xl font-bold"
9
9
  SUFFIX_CLASSES = "text-lg text-gray-500 dark:text-gray-400"
10
- DISCLOSURE_CLASSES = "hidden mt-4 space-y-1 border-t border-gray-200 pt-3 text-sm text-gray-600 dark:border-zinc-700 dark:text-gray-400"
11
- INFO_BUTTON_CLASSES = "shrink-0 text-gray-400 transition hover:text-accent-600 dark:hover:text-accent-400"
10
+ DISCLOSURE_CLASSES = "hidden peer-hover:block peer-focus-visible:block absolute inset-x-0 top-full z-10 mt-2 space-y-1 rounded-lg border border-gray-200 bg-white p-3 text-sm text-gray-600 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-400"
11
+ VALUE_LINK_CLASSES = "hover:underline focus:outline-none focus-visible:underline"
12
+ INFO_BUTTON_CLASSES = "peer shrink-0 text-gray-400 transition hover:text-accent-600 dark:hover:text-accent-400"
12
13
 
13
14
  INFO_ICON = <<~SVG.freeze
14
15
  <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
@@ -22,9 +23,9 @@ module Keystone
22
23
  info: "text-accent-600 dark:text-accent-400"
23
24
  }.freeze
24
25
 
25
- attr_reader :label, :value, :suffix, :definition, :calculation, :change
26
+ attr_reader :label, :value, :suffix, :definition, :calculation, :change, :href
26
27
 
27
- def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil)
28
+ def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)
28
29
  @label = label
29
30
  @value = value
30
31
  @variant = variant
@@ -32,6 +33,7 @@ module Keystone
32
33
  @definition = definition
33
34
  @calculation = calculation
34
35
  @change = change
36
+ @href = href
35
37
  end
36
38
 
37
39
  def classes
@@ -62,6 +64,14 @@ module Keystone
62
64
  !@change.nil?
63
65
  end
64
66
 
67
+ def link?
68
+ !@href.nil?
69
+ end
70
+
71
+ def value_link_classes
72
+ VALUE_LINK_CLASSES
73
+ end
74
+
65
75
  def change_label
66
76
  formatted = "#{format("%.1f", @change.abs)}%"
67
77
  return formatted if @change.zero?
@@ -3,9 +3,9 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class TextareaComponent < ViewComponent::Base
6
- BASE_CLASSES = "block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-1 dark:bg-zinc-900 dark:border-zinc-700 dark:text-white dark:placeholder:text-gray-500"
6
+ BASE_CLASSES = "ks-input"
7
7
 
8
- DISABLED_CLASSES = "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-zinc-800 dark:text-gray-400"
8
+ DISABLED_CLASSES = "ks-input-disabled"
9
9
 
10
10
  def initialize(name:, value: nil, rows: 3, placeholder: nil, disabled: false)
11
11
  @name = name
@@ -15,10 +15,8 @@ module Keystone
15
15
  @disabled = disabled
16
16
  end
17
17
 
18
- FOCUS_CLASSES = "focus:border-accent-500 focus:ring-accent-500 dark:focus:border-accent-400 dark:focus:ring-accent-400"
19
-
20
18
  def classes
21
- tokens = [ BASE_CLASSES, FOCUS_CLASSES ]
19
+ tokens = [ BASE_CLASSES ]
22
20
  tokens << DISABLED_CLASSES if @disabled
23
21
  tokens.join(" ")
24
22
  end
@@ -0,0 +1,10 @@
1
+ <div class="<%= GROUP_CLASSES %>" role="group" aria-label="Theme" data-controller="theme-toggle">
2
+ <% options.each do |label, mode| %>
3
+ <button
4
+ type="button"
5
+ class="<%= OPTION_CLASSES %>"
6
+ aria-pressed="<%= pressed?(mode) %>"
7
+ data-action="theme-toggle#choose"
8
+ data-theme-toggle-mode-param="<%= mode %>"><%= label %></button>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class ThemeToggleComponent < ViewComponent::Base
6
+ OPTIONS = [ [ "Light", "light" ], [ "Dark", "dark" ], [ "System", "system" ] ].freeze
7
+ GROUP_CLASSES = "inline-flex gap-1"
8
+ OPTION_CLASSES = "ks-button ks-button-sm ks-button-secondary aria-pressed:bg-accent-600 aria-pressed:hover:bg-accent-500"
9
+
10
+ def initialize(current: nil)
11
+ @current = current
12
+ end
13
+
14
+ def options
15
+ OPTIONS
16
+ end
17
+
18
+ def pressed?(mode)
19
+ mode == (KeystoneUi::ThemeChoice::MODES.include?(@current) ? @current : "system")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -41,6 +41,14 @@ module KeystoneUiHelper
41
41
  render Keystone::Ui::BadgeComponent.new(**args)
42
42
  end
43
43
 
44
+ def ui_theme_toggle
45
+ render Keystone::Ui::ThemeToggleComponent.new(current: cookies[KeystoneUi::ThemeChoice::COOKIE])
46
+ end
47
+
48
+ def keystone_theme_attributes
49
+ KeystoneUi::ThemeChoice.new(cookies[KeystoneUi::ThemeChoice::COOKIE]).html_attributes_markup.html_safe
50
+ end
51
+
44
52
  def ui_copy_button(**args)
45
53
  render Keystone::Ui::CopyButtonComponent.new(**args)
46
54
  end
data/config/importmap.rb CHANGED
@@ -13,6 +13,7 @@ pin "keystone_ui/accordion_controller", to: "keystone_ui/accordion_controller.js
13
13
  pin "keystone_ui/stat_card_info_controller", to: "keystone_ui/stat_card_info_controller.js"
14
14
  pin "keystone_ui/auto_submit_controller", to: "keystone_ui/auto_submit_controller.js"
15
15
  pin "keystone_ui/line_chart_controller", to: "keystone_ui/line_chart_controller.js"
16
+ pin "keystone_ui/theme_toggle_controller", to: "keystone_ui/theme_toggle_controller.js"
16
17
 
17
18
  # Chart.js for the line-chart controller, so host apps don't pin it themselves.
18
19
  # Vendored as a single self-contained bundle (deps inlined, no external imports).
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "layout_theme_attributes"
4
+
3
5
  module Keystone
4
6
  class InstallGenerator < Rails::Generators::Base
5
7
  desc "Set up Keystone UI in your Rails application"
@@ -11,6 +13,8 @@ module Keystone
11
13
  JS_IMPORT = 'import { registerControllers } from "keystone_ui/index"'
12
14
  JS_REGISTER = "registerControllers(application)"
13
15
 
16
+ LAYOUT_PATH = "app/views/layouts/application.html.erb"
17
+
14
18
  def setup_tailwind
15
19
  say ""
16
20
  say "Keystone UI — setup", :green
@@ -86,5 +90,26 @@ module Keystone
86
90
  append_to_file js_path, "\n#{JS_IMPORT}\n#{JS_REGISTER}\n"
87
91
  say " ✔ Registered Keystone UI controllers", :green
88
92
  end
93
+
94
+ def setup_theme
95
+ say ""
96
+ say "Wiring the Keystone UI theme toggle...", :green
97
+
98
+ layout_path = Rails.root.join(LAYOUT_PATH)
99
+ unless layout_path.exist?
100
+ say " ⚠ #{LAYOUT_PATH} not found — add `<%= keystone_theme_attributes %>` to your <html> tag manually.", :yellow
101
+ return
102
+ end
103
+
104
+ layout = layout_path.read
105
+ updated = Keystone::LayoutThemeAttributes.new(layout).apply
106
+
107
+ if updated == layout
108
+ say " ✔ Layout already marks the theme", :green
109
+ else
110
+ File.write(layout_path, updated)
111
+ say " ✔ Added the theme attributes to the layout's html tag", :green
112
+ end
113
+ end
89
114
  end
90
115
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ class LayoutThemeAttributes
5
+ HELPER_CALL = "<%= keystone_theme_attributes %>"
6
+
7
+ def initialize(layout)
8
+ @layout = layout
9
+ end
10
+
11
+ def apply
12
+ return @layout if @layout.include?(HELPER_CALL)
13
+
14
+ @layout.sub(/<html\b/, "<html #{HELPER_CALL}")
15
+ end
16
+ end
17
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "keystone_ui/source_css"
4
+
3
5
  module KeystoneUi
4
6
  class Engine < ::Rails::Engine
5
7
  config.autoload_paths << root.join("app/components")
@@ -27,21 +29,7 @@ module KeystoneUi
27
29
  keystone_import = '@import "./keystone_source.css";'
28
30
  next unless css_path.read.include?(keystone_import)
29
31
 
30
- source_css = tailwind_dir.join("keystone_source.css")
31
- lines = [ %(@source "#{root}/app/components/**/*.{erb,rb}";) ]
32
-
33
- # Import theme CSS (accent + surface custom property defaults)
34
- theme_css = root.join("app/assets/tailwind/keystone_ui_engine/theme.css")
35
- lines << %(@import "#{theme_css}";) if theme_css.exist?
36
-
37
- # Import component CSS files shipped with the gem
38
- nav_css = root.join("app/assets/tailwind/keystone_ui_engine/nav.css")
39
- lines << %(@import "#{nav_css}";) if nav_css.exist?
40
-
41
- color_picker_css = root.join("app/assets/tailwind/keystone_ui_engine/color_picker.css")
42
- lines << %(@import "#{color_picker_css}";) if color_picker_css.exist?
43
-
44
- source_css.write(lines.join("\n") + "\n")
32
+ tailwind_dir.join("keystone_source.css").write(KeystoneUi::SourceCss.new(root).to_s)
45
33
  end
46
34
  end
47
35
  end
@@ -52,7 +52,8 @@ module Keystone
52
52
  Keystone::Ui::FunnelComponent,
53
53
  Keystone::Ui::PipelineComponent,
54
54
  Keystone::Ui::CodeComponent,
55
- Keystone::Ui::DisclosureComponent
55
+ Keystone::Ui::DisclosureComponent,
56
+ Keystone::Ui::ThemeToggleComponent
56
57
  ].freeze
57
58
 
58
59
  # Constants that hold non-CSS values (e.g. HTML input type maps)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "keystone_ui/styles"
5
+
6
+ module KeystoneUi
7
+ class SourceCss
8
+ def initialize(root)
9
+ @root = Pathname.new(root)
10
+ end
11
+
12
+ def to_s
13
+ lines.join("\n") + "\n"
14
+ end
15
+
16
+ private
17
+
18
+ def styles_entry
19
+ KeystoneUi::Styles::Engine.root.join("app/assets/tailwind/keystone_ui_styles/engine.css")
20
+ end
21
+
22
+ def lines
23
+ [
24
+ %(@import "#{styles_entry}";),
25
+ %(@source "#{@root}/app/components/**/*.{erb,rb}";),
26
+ %(@import "#{@root}/app/assets/tailwind/keystone_ui_engine/nav.css";),
27
+ %(@import "#{@root}/app/assets/tailwind/keystone_ui_engine/color_picker.css";)
28
+ ]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ class ThemeChoice
5
+ COOKIE = "keystone_theme"
6
+ MODES = %w[light dark].freeze
7
+
8
+ def initialize(mode)
9
+ @mode = mode
10
+ end
11
+
12
+ def html_attributes
13
+ MODES.include?(@mode) ? { "data-theme" => @mode } : {}
14
+ end
15
+
16
+ def html_attributes_markup
17
+ html_attributes.map { |name, value| %(#{name}="#{value}") }.join(" ")
18
+ end
19
+ end
20
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeystoneUi
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/keystone_ui.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "view_component"
4
+ require "keystone_ui/styles"
4
5
  require "keystone_ui/configuration"
6
+ require "keystone_ui/theme_choice"
5
7
  require "keystone_ui/engine"
6
8
  require "keystone_ui/version"
7
9
 
@@ -157,11 +157,14 @@ one raises at render time.
157
157
  close control.
158
158
  - `ui_progress(value:, max:, label: nil)` — a labeled progress bar. The percent
159
159
  is `value / max`, rounded and clamped at 100.
160
- - `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil)`
160
+ - `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)`
161
161
  — a single metric tile. `variant:` `:neutral` `:success` `:danger` `:warning`
162
162
  `:info` colors the value. `change:` is a signed number rendered as `▲ 4.2%` in
163
- green when positive, `▼` in red when negative, plain when zero. Passing
164
- `definition:` and/or `calculation:` adds an info button that reveals them.
163
+ green when positive, `▼` in red when negative, plain when zero. `href:` makes
164
+ the value a link to its drill-down screen; only the value is linked, never the
165
+ whole card. Passing `definition:` and/or `calculation:` adds an info button
166
+ whose details show in a panel floating below the card while the button is
167
+ hovered or focused, and tapping the button toggles it.
165
168
  - `ui_copy_button(text:, label: "Copy", success_message: "Copied!", error_message: "Failed!")`
166
169
  — copies `text:` to the clipboard.
167
170
  - `ui_code(language: nil, caption: nil)` — takes a block holding the code. The
@@ -242,7 +245,8 @@ one raises at render time.
242
245
  5. Fill the body with the leaf helpers from the Interface above. Reach for the
243
246
  most specific one that fits — `ui_form_field` over `ui_input`,
244
247
  `ui_data_table` over a hand-built `<table>`, `ui_stat_card` over a panel with
245
- text in it.
248
+ text in it. To make a stat card clickable, pass `href:` — never wrap it in
249
+ `ui_card_link`, because a tap on the info button would then follow the link.
246
250
 
247
251
  6. For a table, decide how columns are declared. Use `{ key: "Label" }` hashes
248
252
  when every column is plain. Switch the whole set to `Keystone::Ui::Column`
@@ -105,6 +105,7 @@ sources:
105
105
  - app/components/keystone/ui/settings_link_component.rb
106
106
  - app/components/keystone/ui/show_page_component.rb
107
107
  - app/components/keystone/ui/stat_card_component.rb
108
+ - app/components/keystone/ui/stat_card_component.html.erb
108
109
  - app/components/keystone/ui/swipe_deck_component.rb
109
110
  - app/components/keystone/ui/tab_switcher_component.rb
110
111
  - app/components/keystone/ui/textarea_component.rb
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: keystone_ui-styles
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.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.1.0
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: view_component
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +51,7 @@ executables: []
37
51
  extensions: []
38
52
  extra_rdoc_files: []
39
53
  files:
54
+ - MIT-LICENSE
40
55
  - app/assets/javascripts/keystone_ui/accordion_controller.js
41
56
  - app/assets/javascripts/keystone_ui/auto_submit_controller.js
42
57
  - app/assets/javascripts/keystone_ui/chartjs.js
@@ -53,10 +68,10 @@ files:
53
68
  - app/assets/javascripts/keystone_ui/stat_card_info_controller.js
54
69
  - app/assets/javascripts/keystone_ui/swipe_deck_controller.js
55
70
  - app/assets/javascripts/keystone_ui/tab_switcher_controller.js
71
+ - app/assets/javascripts/keystone_ui/theme_toggle_controller.js
56
72
  - app/assets/tailwind/keystone_ui_engine/color_picker.css
57
73
  - app/assets/tailwind/keystone_ui_engine/engine.css
58
74
  - app/assets/tailwind/keystone_ui_engine/nav.css
59
- - app/assets/tailwind/keystone_ui_engine/theme.css
60
75
  - app/components/keystone/ui/accordion_component.html.erb
61
76
  - app/components/keystone/ui/accordion_component.rb
62
77
  - app/components/keystone/ui/alert_component.html.erb
@@ -154,13 +169,18 @@ files:
154
169
  - app/components/keystone/ui/tab_switcher_component.rb
155
170
  - app/components/keystone/ui/textarea_component.html.erb
156
171
  - app/components/keystone/ui/textarea_component.rb
172
+ - app/components/keystone/ui/theme_toggle_component.html.erb
173
+ - app/components/keystone/ui/theme_toggle_component.rb
157
174
  - app/helpers/keystone_ui_helper.rb
158
175
  - config/importmap.rb
159
176
  - lib/generators/keystone/install_generator.rb
177
+ - lib/generators/keystone/layout_theme_attributes.rb
160
178
  - lib/keystone_ui.rb
161
179
  - lib/keystone_ui/configuration.rb
162
180
  - lib/keystone_ui/engine.rb
163
181
  - lib/keystone_ui/safelist.rb
182
+ - lib/keystone_ui/source_css.rb
183
+ - lib/keystone_ui/theme_choice.rb
164
184
  - lib/keystone_ui/version.rb
165
185
  - the_local/agents/keystone_ui-develop.md
166
186
  - the_local/agents/keystone_ui-info.md
@@ -1,32 +0,0 @@
1
- /* Keystone UI theme — defines accent and surface color tokens as CSS custom properties.
2
- Apps override these variables to customize the color palette. */
3
-
4
- @theme {
5
- /* Accent — defaults to blue */
6
- --color-accent-50: #eff6ff;
7
- --color-accent-100: #dbeafe;
8
- --color-accent-200: #bfdbfe;
9
- --color-accent-300: #93c5fd;
10
- --color-accent-400: #60a5fa;
11
- --color-accent-500: #3b82f6;
12
- --color-accent-600: #2563eb;
13
- --color-accent-700: #1d4ed8;
14
- --color-accent-800: #1e40af;
15
- --color-accent-900: #1e3a8a;
16
-
17
- /* Surface — defaults to zinc */
18
- --color-surface-50: #fafafa;
19
- --color-surface-100: #f4f4f5;
20
- --color-surface-200: #e4e4e7;
21
- --color-surface-300: #d4d4d8;
22
- --color-surface-400: #a1a1aa;
23
- --color-surface-500: #71717a;
24
- --color-surface-600: #52525b;
25
- --color-surface-700: #3f3f46;
26
- --color-surface-800: #27272a;
27
- --color-surface-900: #18181b;
28
- --color-surface-950: #09090b;
29
-
30
- /* Accent — darkest shade */
31
- --color-accent-950: #172554;
32
- }