keystone_ui 0.12.2 → 0.13.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: 702c1e314aec36dbb95ed4f5541c4bdab916eba0e92f19a85f74ff641ed183f0
4
- data.tar.gz: 0252fa96678cb96ab89cae69ad368cc5139ef30ea3c2f8c4ffdfe880a1e511e9
3
+ metadata.gz: 903dfcff87cb63fce06692a39fb26b73af673f562f28f3b3ebf72b13b5280642
4
+ data.tar.gz: 7db5a26e387cc0c2b11a311c70e967ec977f5b1636c6d38bf8a42b28d5ffbd9a
5
5
  SHA512:
6
- metadata.gz: 9e8be46029fad363c665045bec462c689ceeec5c5b31a4cdec2de99e1a0f661506d8dcc09585d4a1457c201174de5f46ff9bd730524fba0506b0403b28bf4bff
7
- data.tar.gz: 22301b268679a53f5c128a1ef20b921cddedf8532f279e3c8065379d72e4a5eaeb64a27f0702bd06593ee4bcd224ab57d468c6c12f6748294061ed9634991021
6
+ metadata.gz: 574997b044dfcbb23b6aad3e74065b041d549d1be5a8629f5c08f792720f1fda5d988340516caa581f80c1455fcc3a32080174b8056f06a41c4fa58423236cbb
7
+ data.tar.gz: 30a7877baf982039cff72b9c8ad0dc879a8fdbc8362d8ec15cec9b7b48964aa8dc1786494b25e3c131e1c28a2c000054c33998011ea41b111da749e1863518f3
@@ -0,0 +1,11 @@
1
+ <div class="<%= container_classes %>">
2
+ <% if label %>
3
+ <span class="<%= label_classes %>"><%= label %></span>
4
+ <% end %>
5
+ <span class="<%= goal_classes %>"><%= goal %></span>
6
+ <div class="<%= tank_classes %>">
7
+ <div class="<%= fill_classes %>" style="height: <%= fill_percent %>%"></div>
8
+ </div>
9
+ <span class="<%= actual_classes %>"><%= actual %></span>
10
+ <span class="<%= percent_classes %>"><%= percent %>%</span>
11
+ </div>
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class BucketComponent < ViewComponent::Base
6
+ CONTAINER_CLASSES = "flex w-24 flex-col items-center gap-1"
7
+ LABEL_CLASSES = "text-center text-sm font-medium text-gray-700 dark:text-gray-300"
8
+ GOAL_CLASSES = "text-xs tabular-nums text-gray-500 dark:text-gray-400"
9
+ TANK_CLASSES = "flex h-40 w-full items-end overflow-hidden rounded-t-sm rounded-b-xl border-2 border-gray-300 bg-gray-50 dark:border-zinc-600 dark:bg-zinc-800"
10
+ ACTUAL_CLASSES = "text-sm font-semibold tabular-nums text-gray-900 dark:text-white"
11
+ PERCENT_CLASSES = "text-xs tabular-nums text-gray-500 dark:text-gray-400"
12
+ FILL_BASE_CLASSES = "w-full transition-all"
13
+ WITHIN_GOAL_FILL_CLASSES = "bg-accent-500"
14
+ OVER_GOAL_FILL_CLASSES = {
15
+ success: "bg-green-500",
16
+ warning: "bg-amber-500"
17
+ }.freeze
18
+
19
+ attr_reader :goal, :actual, :label
20
+
21
+ def initialize(goal:, actual:, label: nil, over: :success)
22
+ @goal = goal
23
+ @actual = actual
24
+ @label = label
25
+ @over_fill_classes = OVER_GOAL_FILL_CLASSES.fetch(over)
26
+ end
27
+
28
+ def percent
29
+ return 0 if goal.zero?
30
+
31
+ (actual.to_f / goal * 100).round
32
+ end
33
+
34
+ def fill_percent
35
+ [ percent, 100 ].min
36
+ end
37
+
38
+ def fill_classes
39
+ "#{FILL_BASE_CLASSES} #{fill_color_classes}"
40
+ end
41
+
42
+ def container_classes
43
+ CONTAINER_CLASSES
44
+ end
45
+
46
+ def label_classes
47
+ LABEL_CLASSES
48
+ end
49
+
50
+ def goal_classes
51
+ GOAL_CLASSES
52
+ end
53
+
54
+ def tank_classes
55
+ TANK_CLASSES
56
+ end
57
+
58
+ def actual_classes
59
+ ACTUAL_CLASSES
60
+ end
61
+
62
+ def percent_classes
63
+ PERCENT_CLASSES
64
+ end
65
+
66
+ private
67
+
68
+ def fill_color_classes
69
+ return @over_fill_classes if actual > goal
70
+
71
+ WITHIN_GOAL_FILL_CLASSES
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ <div class="<%= row_classes %>">
2
+ <% bucket_components.each do |bucket| %>
3
+ <%= render bucket %>
4
+ <% end %>
5
+ </div>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class BucketSeriesComponent < ViewComponent::Base
6
+ ROW_CLASSES = "flex flex-wrap justify-center gap-4 sm:justify-start"
7
+
8
+ def initialize(buckets:)
9
+ @buckets = buckets
10
+ end
11
+
12
+ def bucket_components
13
+ @buckets.map { |bucket| BucketComponent.new(**bucket) }
14
+ end
15
+
16
+ def row_classes
17
+ ROW_CLASSES
18
+ end
19
+ end
20
+ end
21
+ end
@@ -8,7 +8,7 @@
8
8
  <span class="<%= label_classes %>"><%= layer.label %></span>
9
9
  <span class="<%= value_classes %>"><%= layer.value %></span>
10
10
  </div>
11
- <div class="<%= bar_classes %>" style="width: <%= layer.width_percent %>%"></div>
11
+ <div class="<%= bar_classes %> <%= layer.color_classes %>" style="width: <%= layer.width_percent %>%"></div>
12
12
  </div>
13
13
  <% end %>
14
14
  </div>
@@ -3,15 +3,22 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class FunnelComponent < ViewComponent::Base
6
- Layer = Struct.new(:label, :value, :width_percent, :conversion_percent, keyword_init: true)
6
+ Layer = Struct.new(:label, :value, :width_percent, :conversion_percent, :color_classes, keyword_init: true)
7
7
 
8
8
  CONTAINER_CLASSES = "space-y-2"
9
9
  LAYER_CLASSES = "space-y-1"
10
10
  ROW_CLASSES = "flex items-baseline justify-between gap-3"
11
- LABEL_CLASSES = "text-sm font-medium text-surface-700 truncate"
12
- VALUE_CLASSES = "text-sm font-semibold text-surface-900 tabular-nums"
13
- BAR_CLASSES = "h-8 rounded-md bg-accent-500 transition-all"
14
- TRANSITION_CLASSES = "py-1 text-center text-xs text-surface-500"
11
+ LABEL_CLASSES = "text-sm font-medium text-surface-700 truncate dark:text-surface-300"
12
+ VALUE_CLASSES = "text-sm font-semibold text-surface-900 tabular-nums dark:text-white"
13
+ BAR_CLASSES = "h-8 rounded-md transition-all"
14
+ TRANSITION_CLASSES = "py-1 text-center text-xs text-surface-500 dark:text-surface-400"
15
+ STEP_COLOR_CLASSES = {
16
+ accent: "bg-accent-500",
17
+ sky: "bg-sky-500",
18
+ violet: "bg-violet-500",
19
+ amber: "bg-amber-500",
20
+ rose: "bg-rose-500"
21
+ }.freeze
15
22
 
16
23
  attr_reader :steps
17
24
 
@@ -22,12 +29,13 @@ module Keystone
22
29
  def layers
23
30
  previous = nil
24
31
 
25
- steps.map do |step|
32
+ steps.each_with_index.map do |step, index|
26
33
  layer = Layer.new(
27
34
  label: step[:label],
28
35
  value: step[:value],
29
36
  width_percent: width_percent(step[:value]),
30
- conversion_percent: conversion_percent(step[:value], previous)
37
+ conversion_percent: conversion_percent(step[:value], previous),
38
+ color_classes: color_classes(step[:color], index)
31
39
  )
32
40
  previous = step[:value]
33
41
  layer
@@ -64,6 +72,12 @@ module Keystone
64
72
 
65
73
  private
66
74
 
75
+ def color_classes(color, index)
76
+ return STEP_COLOR_CLASSES.fetch(color) if color
77
+
78
+ STEP_COLOR_CLASSES.values[index % STEP_COLOR_CLASSES.size]
79
+ end
80
+
67
81
  def conversion_percent(value, previous)
68
82
  return nil if previous.nil?
69
83
  return 0 if previous.zero?
@@ -3,9 +3,9 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class ProgressComponent < ViewComponent::Base
6
- TRACK_CLASSES = "w-full h-2 bg-surface-200 rounded-full overflow-hidden"
6
+ TRACK_CLASSES = "w-full h-2 bg-surface-200 rounded-full overflow-hidden dark:bg-surface-700"
7
7
  BAR_CLASSES = "h-full bg-accent-500 rounded-full transition-all"
8
- LABEL_CLASSES = "mb-1 text-sm font-medium text-surface-700"
8
+ LABEL_CLASSES = "mb-1 text-sm font-medium text-surface-700 dark:text-surface-300"
9
9
 
10
10
  attr_reader :value, :max, :label
11
11
 
@@ -193,6 +193,14 @@ module KeystoneUiHelper
193
193
  render Keystone::Ui::FunnelComponent.new(**args)
194
194
  end
195
195
 
196
+ def ui_bucket(**args)
197
+ render Keystone::Ui::BucketComponent.new(**args)
198
+ end
199
+
200
+ def ui_bucket_series(**args)
201
+ render Keystone::Ui::BucketSeriesComponent.new(**args)
202
+ end
203
+
196
204
  def ui_pipeline(**args)
197
205
  render Keystone::Ui::PipelineComponent.new(**args)
198
206
  end
@@ -51,6 +51,8 @@ module Keystone
51
51
  Keystone::Ui::FormComponent,
52
52
  Keystone::Ui::FileUploadComponent,
53
53
  Keystone::Ui::FunnelComponent,
54
+ Keystone::Ui::BucketComponent,
55
+ Keystone::Ui::BucketSeriesComponent,
54
56
  Keystone::Ui::PipelineComponent,
55
57
  Keystone::Ui::CodeComponent,
56
58
  Keystone::Ui::DisclosureComponent,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeystoneUi
4
- VERSION = "0.12.2"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -29,17 +29,23 @@ this one's.
29
29
 
30
30
  Every entry point is a view helper called from ERB. Keywords with defaults are
31
31
  optional; the rest are required. Symbol options are validated — an unrecognized
32
- one raises at render time.
32
+ one raises at render time. Two exceptions: `ui_page`'s `padding:` treats any
33
+ value other than `:none` as `:standard`, and `ui_pipeline`'s box `accent:` falls
34
+ back to `:muted`.
35
+
36
+ Six helpers — `ui_page`, `ui_section`, `ui_page_header`, `ui_card`, `ui_alert`,
37
+ `ui_badge` — also accept `class:`, a string of classes appended to the helper's
38
+ outer element. See Conventions before using it.
33
39
 
34
40
  ### Page shells and layout
35
41
 
36
- - `ui_page(max_width: :full, padding: :standard, top_offset: nil)` — takes a
37
- block. The outer wrapper for a screen. `max_width:` `:sm` `:md` `:lg` `:xl`
38
- `:full` (anything but `:full` also centers); `padding:` `:standard` or `:none`;
39
- `top_offset:` `:sm` `:md` `:lg` `:xl` to clear a fixed navbar.
40
- - `ui_section(title: nil, subtitle: nil, action: nil, spacing: :md)` — takes a
41
- block. A titled block of content with an optional right-aligned link.
42
- `action:` is `{ label:, href: }`; `spacing:` `:sm` `:md` `:lg`.
42
+ - `ui_page(max_width: :full, padding: :standard, top_offset: nil, class: nil)` —
43
+ takes a block. The outer wrapper for a screen. `max_width:` `:sm` `:md` `:lg`
44
+ `:xl` `:full`; `padding:` `:standard` or `:none`; `top_offset:` `:sm` `:md`
45
+ `:lg` `:xl` to clear a fixed navbar.
46
+ - `ui_section(title: nil, subtitle: nil, action: nil, spacing: :md, class: nil)`
47
+ — takes a block. A titled block of content with an optional right-aligned
48
+ link. `action:` is `{ label:, href: }`; `spacing:` `:sm` `:md` `:lg`.
43
49
  - `ui_panel(padding: :md, radius: :lg, shadow: true)` — takes a block. A bordered
44
50
  card surface. `padding:` `:sm` `:md` `:lg`; `radius:` `:md` `:lg` `:xl`.
45
51
  - `ui_grid(cols: { default: 1 }, gap: :md, gap_x: nil, gap_y: nil)` — takes a
@@ -48,10 +54,10 @@ one raises at render time.
48
54
  `:xl`; passing `gap_x:`/`gap_y:` replaces `gap:` entirely.
49
55
  - `ui_card_link(href:, padding: :md, shadow: true)` — takes a block. A whole
50
56
  panel that is one link. `padding:` `:sm` `:md` `:lg`.
51
- - `ui_card(title:, summary:, link:, cta: "Read more", edge_to_edge: false)` — a
52
- fixed title/summary/CTA card. `edge_to_edge: true` drops the side border and
57
+ - `ui_card(title:, summary:, link:, cta: "Read more", edge_to_edge: false, class: nil)`
58
+ — a fixed title/summary/CTA card. `edge_to_edge: true` drops the side border and
53
59
  corner rounding below `sm:` so it spans the full width on mobile.
54
- - `ui_page_header(title:, subtitle: nil, action_url: nil, action_label: "Add new")`
60
+ - `ui_page_header(title:, subtitle: nil, action_url: nil, action_label: "Add new", class: nil)`
55
61
  — takes a block yielding the header. Desktop-only page title (hidden below
56
62
  `sm:`). Call `header.action { ... }` in the block to place a custom control on
57
63
  the right; only what `action` receives is rendered. Passing `action_url:`
@@ -93,10 +99,14 @@ one raises at render time.
93
99
  - `ui_form_field(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [])`
94
100
  — a labeled field with hint and error text. This is the default way to render
95
101
  an input. `type:` `:text` `:number` `:email` `:password` `:date` `:textarea`
96
- `:checkbox` `:select`. `label:` defaults to the humanized attribute name.
97
- `options:` is for `:select` and takes `[[label, value], ...]`; a non-required
98
- select gets a leading blank option. A `:checkbox` submits `"0"` when unchecked
99
- and `"1"` when checked, and pre-checks when `value:` is `"1"`. `errors:` is an
102
+ `:checkbox` `:select`. `attribute:` is used verbatim as the input's `name`, so
103
+ pass the full param name the controller expects, e.g. `"quote[title]"`, and
104
+ pass `label:` whenever the attribute is a nested name. `label:` defaults to the
105
+ attribute with underscores turned to spaces and the first letter capitalized.
106
+ `options:` is for `:select` and takes `[[label, value], ...]`; `value:` picks
107
+ the selected option, and a non-required select gets a leading blank option. A
108
+ `:checkbox` renders its label beside the box, submits `"0"` when unchecked and
109
+ `"1"` when checked, and pre-checks when `value:` is `"1"`. `errors:` is an
100
110
  array of message strings.
101
111
  - `ui_input(name:, type: :text, value: nil, placeholder: nil, disabled: false, min: nil, max: nil, step: nil)`
102
112
  — a bare styled input with no label. `type:` `:text` `:number` `:email`
@@ -107,9 +117,10 @@ one raises at render time.
107
117
  — a bare styled select. `options:` is `[[label, value], ...]`;
108
118
  `include_blank:` is the text of a leading empty option.
109
119
  - `ui_multi_select(name:, label:, options:, selected: [])` — a dropdown of
110
- checkboxes all posting under `name`. `options:` is `[[label, value], ...]`;
111
- the trigger reads "All <label>" when nothing is checked and "N selected"
112
- otherwise.
120
+ checkboxes all posting under `name`, used verbatim; pass an array name such as
121
+ `"status[]"` so every checked value arrives. `options:` is
122
+ `[[label, value], ...]`; `selected:` is the values to pre-check. The trigger
123
+ reads "All <label>" when nothing is checked and "N selected" otherwise.
113
124
  - `ui_file_upload(name:, label: nil, accept: nil, multiple: false, hint: nil)` —
114
125
  a drop zone with drag-and-drop and selected-file feedback. Requires the
115
126
  enclosing form to be multipart.
@@ -155,9 +166,10 @@ one raises at render time.
155
166
  — renders an `<a>` when `href:` is given and a `<button>` otherwise. `variant:`
156
167
  `:primary` `:secondary` `:danger`; `size:` `:sm` `:md` `:lg`; `type:` applies
157
168
  only to the button form.
158
- - `ui_badge(label:, variant: :neutral)` — a pill. `variant:` `:neutral`
159
- `:success` `:danger` `:warning` `:info`.
160
- - `ui_alert(message:, type: :info, title: nil, dismissible: false)` — a banner.
169
+ - `ui_badge(label:, variant: :neutral, class: nil)` — a pill. `variant:`
170
+ `:neutral` `:success` `:danger` `:warning` `:info`.
171
+ - `ui_alert(message:, type: :info, title: nil, dismissible: false, class: nil)` —
172
+ a banner.
161
173
  `type:` `:info` `:success` `:warning` `:error`. `dismissible: true` adds a
162
174
  close control.
163
175
  - `ui_progress(value:, max:, label: nil)` — a labeled progress bar. The percent
@@ -185,14 +197,19 @@ one raises at render time.
185
197
  tab dispatches a `tab-switcher:change` event carrying the clicked index —
186
198
  showing and hiding the matching panels is the app's job.
187
199
  - `ui_modal(title:, size: :md)` — takes a block holding the body. `size:` `:sm`
188
- `:md` `:lg` `:xl`. Renders hidden; it closes on its own close button and on a
189
- backdrop click, but the app must supply the control that opens it by targeting
190
- the modal controller's `open` action.
200
+ `:md` `:lg` `:xl`. Renders hidden, and closes on its own close button and on a
201
+ backdrop click. Nothing in the gem opens it: the modal's outermost element
202
+ carries the `hidden` class, and the app's own code must remove that class to
203
+ show it. A `data-action="modal#open"` placed outside the modal does nothing.
191
204
  - `ui_swipe_deck(items:, empty_title: "All done!", empty_subtitle: nil)` — takes
192
205
  a block yielding the deck. Call `deck.item { |item| ... }` in the block to
193
- render one card's face. Accepting a card dispatches `swipe-deck:complete` and
194
- rejecting dispatches `swipe-deck:skip`, both carrying the item's id — the app
195
- must listen and persist the outcome.
206
+ render one card's face. Accepting a card (button or swipe right) dispatches a
207
+ bubbling `swipe-deck:complete` event and rejecting (button or swipe left)
208
+ dispatches `swipe-deck:skip`. Both carry `detail.itemId` — the item's `id`, or
209
+ its position in `items:` when it has none — and `detail.card`; `complete` also
210
+ carries `detail.value`, read from an input inside the card's face marked
211
+ `data-swipe-deck-value`, or `null`. The app must listen and persist the
212
+ outcome.
196
213
 
197
214
  ### Charts and analytics
198
215
 
@@ -200,10 +217,27 @@ one raises at render time.
200
217
  `:sm` `:md` `:lg`.
201
218
  - `ui_line_chart(series:, labels:, height: :md)` — a line chart. `labels:` is the
202
219
  x-axis labels; `series:` is `[{ name:, data:, color:, dashed: }, ...]` where
203
- `color:` and `dashed:` are optional. `height:` `:sm` `:md` `:lg`.
204
- - `ui_funnel(steps:)` — a conversion funnel. `steps:` is `[{ label:, value: }, ...]`
205
- in order. Bar widths are relative to the first step; the caption between two
206
- layers is the step-to-step conversion. Divide-by-zero safe, no JavaScript.
220
+ `color:` (a CSS color string for the line) and `dashed: true` are optional.
221
+ `height:` `:sm` `:md` `:lg`.
222
+ - `ui_funnel(steps:)` — a conversion funnel. `steps:` is
223
+ `[{ label:, value:, color: }, ...]` in order, with `color:` optional. Bar
224
+ widths are relative to the first step; the caption between two layers is the
225
+ step-to-step conversion. Each bar takes the next colour in the order
226
+ `:accent` `:sky` `:violet` `:amber` `:rose`, starting again after `:rose`; a
227
+ step passing one of those symbols as `color:` uses it instead, and any other
228
+ symbol raises. Divide-by-zero safe, no JavaScript.
229
+ - `ui_bucket(goal:, actual:, label: nil, over: :success)` — an upright container
230
+ for one target, filled from the bottom toward `goal:`. It shows the optional
231
+ `label` on top, then `goal`, the container, `actual`, and the percent reached.
232
+ `goal` and `actual` are printed exactly as given, so pass numbers already
233
+ formatted as needed. The percent is `actual / goal * 100` rounded and **not**
234
+ clamped (so 150% shows as 150%), and it is 0 when `goal` is zero. The fill
235
+ stops at the top once the goal is reached. Within the goal the fill uses the
236
+ accent colour; over it the fill turns green with `over: :success` or amber with
237
+ `over: :warning`, and any other symbol raises. No JavaScript.
238
+ - `ui_bucket_series(buckets:)` — a row of buckets that wraps onto more rows on
239
+ narrow screens. `buckets:` is an array of hashes, each taking the same keywords
240
+ as `ui_bucket`, e.g. `[{ goal: 10, actual: 7, label: "Mon" }, ...]`.
207
241
  - `ui_pipeline(title:, boxes:, links:, subtitle: nil)` — a staged flow diagram
208
242
  for event flows, approval chains, or state machines. `boxes:` is
209
243
  `[{ label:, count:, accent:, action: }, ...]` where `count:` and `accent:`
@@ -281,8 +315,13 @@ one raises at render time.
281
315
  layered onto a helper's output fight the component and drift the moment the gem
282
316
  updates. The gem owns spacing, color, borders, radius, shadow, and dark mode.
283
317
  - **Never restyle a helper from the outside** — no wrapper div that overrides its
284
- padding or width, no `class:` smuggled through, no CSS targeting its markup.
285
- Choose a different option symbol instead, or say the helper does not fit.
318
+ padding or width, no CSS targeting its markup. Choose a different option
319
+ symbol instead, or say the helper does not fit.
320
+ - **`class:` needs the developer's approval.** The six helpers that accept it
321
+ append whatever it holds to their outer element, so it can override anything
322
+ the gem sets. Before passing it, name the class and the reason to the
323
+ developer and wait for a yes; if the same class keeps being needed, it is a
324
+ missing option in the gem.
286
325
  - **Semantic color only.** Themed color is `accent-*` (the brand hue) and
287
326
  `surface-*` (the neutral family). Never write a literal color into a view;
288
327
  changing the palette is install-local territory.
@@ -1,81 +1,89 @@
1
1
  ---
2
2
  name: keystone_ui-info
3
- description: Use to learn what Keystone UI offers — what the component system covers, its layout and color model, and the vocabulary the install and develop locals assume.
3
+ description: Use to learn what Keystone UI offers — what the component system covers, its layout, color and theme model, and the vocabulary the install and develop locals assume.
4
4
  tools: Read
5
5
  scope: UI — pages, forms, tables, navigation, dashboards
6
6
  ---
7
7
 
8
- This local explains Keystone UI and hands you off to the local that does the
9
- work. It changes nothing and gives no steps.
8
+ This local explains Keystone UI and sends you to the local that does the work.
9
+ It changes nothing and gives no steps.
10
10
 
11
11
  ## What Keystone UI is
12
12
 
13
- Keystone UI is a Rails engine gem that supplies a host app's entire visual
14
- layer as a library of view helpers built on ViewComponent. Screens are composed
15
- out of named pieces — page shells, sections, panels, grids, form fields, data
16
- tables, navigation bars, cards, charts, banners — instead of hand-written ERB
17
- and Tailwind. Every class the UI renders lives inside the gem, in frozen
18
- constants, so the look is owned in one place.
13
+ Keystone UI is a Rails engine gem that supplies a host app's visual layer as a
14
+ library of view helpers built on ViewComponent. Screens are built from named
15
+ pieces — page shells, sections, panels, grids, form fields, data tables,
16
+ navigation bars, cards, stat tiles, charts, funnels, goal buckets, pipelines,
17
+ banners — instead of hand-written ERB and Tailwind. Every class the UI renders
18
+ lives inside the gem, in frozen constants, so the look is defined in one place.
19
19
 
20
- Reach for it whenever you are building or changing a screen in an app that has
21
- it installed. The point is to stop UI drift: two pages built from the same
22
- helpers cannot disagree about spacing, color, or dark-mode treatment, and a
23
- change to a component updates every page at once. It is mobile-first by
24
- construction — components ship distinct mobile and desktop treatments (a bottom
25
- tab bar and mobile header on small screens, a full navigation bar above the
26
- `lg:` breakpoint), which matters because these apps are often viewed in a native
27
- webview.
20
+ Reach for it whenever you build or change a screen in an app that has it
21
+ installed. It exists to stop UI drift: two pages built from the same helpers
22
+ cannot disagree about spacing, color or dark-mode treatment, and a change to a
23
+ component updates every page that uses it. It is mobile-first — components ship
24
+ separate mobile and desktop treatments (a bottom tab bar and mobile header on
25
+ small screens, a full navigation bar from the `lg:` breakpoint up), because
26
+ these apps are often viewed in a native webview.
28
27
 
29
28
  ## Interface
30
29
 
31
30
  This local declares no commands. The two working surfaces belong elsewhere:
32
31
 
33
- - **Getting the gem into a host app** — adding it, wiring Tailwind and Stimulus,
34
- setting the palette, refreshing the generated reference → **`keystone_ui-install`**.
32
+ - **Getting the gem into a host app** — adding it, wiring Tailwind, Stimulus and
33
+ the theme attributes on the layout, setting the palette → **`keystone_ui-install`**.
35
34
  - **Building UI with it** — which helper renders what, what keywords it takes,
36
35
  how helpers nest → **`keystone_ui-develop`**.
37
36
 
38
37
  ## How to use it
39
38
 
40
- One decision: is the app already wearing Keystone UI?
39
+ One decision: is Keystone UI already set up in the app?
41
40
 
42
- - No, or it is out of date → **`keystone_ui-install`**.
41
+ - No, or the setup is out of date → **`keystone_ui-install`**.
43
42
  - Yes, and you have a screen to build or edit → **`keystone_ui-develop`**. Do not
44
43
  hand-write ERB or Tailwind for UI it covers.
45
44
 
46
- Questions about which piece fits a scenario are also answered by the develop
47
- local — it owns the catalog.
45
+ Questions about which piece fits a scenario also go to the develop local, which
46
+ holds the catalog.
48
47
 
49
48
  ## Conventions
50
49
 
51
50
  - **Helpers, not classes.** Every entry point is a view helper prefixed `ui_`,
52
51
  called from ERB. Components live under the `Keystone::Ui` namespace, but a
53
- host app should not name a component class directly.
54
- - **Containers take blocks; leaves take keywords.** Helpers that wrap content
55
- (page shells, sections, panels, grids, forms, tables) yield a block; helpers
56
- that render one thing (a button, a badge, a field, a stat) are configured
57
- entirely by keyword arguments. Composite pieces such as the navigation bar
58
- expose named slots rather than a single block.
59
- - **Options are symbols, and the accepted set is per-component.** Appearance is
52
+ host app does not name a component class directly. The one exception is the
53
+ table column value object, which is passed as an argument and renders nothing.
54
+ - **Containers take blocks, leaves take keywords.** Helpers that wrap content
55
+ (page shells, sections, panels, grids, forms, tables) yield a block. Helpers
56
+ that render one thing (a button, a badge, a field, a stat, a bucket) are
57
+ configured entirely by keyword arguments. The navigation bar exposes named
58
+ slots instead of a single block.
59
+ - **Options are symbols, and each component accepts its own set.** Appearance is
60
60
  chosen by name — `variant:`, `size:`, `type:`, `padding:`, `spacing:`,
61
- `max_width:`, `radius:` — on a small t-shirt scale (`:sm` … `:xl`) or a short
62
- semantic list. Do not assume one global vocabulary: a button's `variant:` and
63
- a badge's `variant:` accept different symbols, and `padding:` means different
64
- things on a page shell than on a panel. The develop local carries the real
65
- values. An unrecognized symbol raises rather than degrading silently, so a
66
- wrong guess fails loudly at render time.
61
+ `max_width:`, `radius:` — on a size scale (`:sm` … `:xl`) or a short list of
62
+ named choices. A button's `variant:` and a badge's `variant:` accept
63
+ different symbols, and the develop local carries the real values. Most
64
+ components raise on a symbol they do not know, so a wrong guess fails at
65
+ render time.
67
66
  - **Semantic color, not literal color.** The themed hue is `accent-*` and the
68
- themed neutral family is `surface-*` — Tailwind v4 CSS custom properties
69
- defaulting to blue and zinc, so retheming an app is a token change, not a
70
- component change. Older components still reach for Tailwind's stock
71
- `gray-*`/`zinc-*` neutrals directly; the accent hue is themed throughout.
72
- Dark-mode variants are already built into every component. Changing the
73
- defaults is install-local territory.
67
+ themed neutral family is `surface-*`, both CSS custom properties whose
68
+ defaults (blue and zinc) come from the keystone_ui-styles gem. Retheming an
69
+ app changes those values, not the components. Some components still use
70
+ Tailwind's stock `gray-*` and `zinc-*` neutrals, or fixed status colors such
71
+ as green and amber, directly. Changing the defaults belongs to the install
72
+ local.
73
+ - **Light, dark and system themes.** Every component has dark-mode styling. The
74
+ theme is chosen in this order: the user's choice stored in a cookie, then a
75
+ mode another gem supplies, then light. System leaves the page to follow the
76
+ operating system.
74
77
  - **Tailwind classes are static strings.** Class names are never interpolated,
75
- so Tailwind's scanner can see them; the host needs tailwindcss-rails v4+ and
76
- the engine tells Tailwind where to look.
78
+ so Tailwind's scanner can find them. Widths and heights that depend on data,
79
+ such as a progress bar or a bucket's fill, are set with an inline style
80
+ instead. The host needs tailwindcss-rails v4+, and the engine tells Tailwind
81
+ at boot where the component files are.
77
82
  - **Interactivity is Stimulus.** Dropdowns, modals, dismissible alerts, file
78
- uploads, tab switchers, column pickers, clipboard copy and similar behavior
79
- ship with the gem as Stimulus controllers registered in one call at install —
80
- a host writes no JavaScript to use them.
81
- - Ruby >= 3.2. ViewComponent >= 2.0.
83
+ uploads, the color picker, the multi-select, tab switchers, accordions, the
84
+ swipe deck, column pickers, stat card info panels, line charts, clipboard copy
85
+ and the theme toggle ship with the gem as Stimulus controllers registered once
86
+ at install. A host writes no JavaScript to use them. Components that post
87
+ somewhere, such as the column picker and the pipeline, post to endpoints the
88
+ host app owns.
89
+ - Ruby >= 3.2. ViewComponent >= 2.0 and < 5.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: keystone_ui-install
3
- description: Use to hook Keystone UI into a project — adding the gem, running the install generator to wire Tailwind and Stimulus, and setting the accent/surface palette.
3
+ description: Use to hook Keystone UI into a project — adding the gem, running the install generator to wire Tailwind, the Stimulus controllers and the layout's theme attributes, and configuring the palette, the theme mode supplier and extra Tailwind imports and sources.
4
4
  tools: Bash, Read, Edit
5
5
  scope: UI — pages, forms, tables, navigation, dashboards
6
6
  ---
@@ -15,16 +15,21 @@ built on ViewComponent; hook it in before building any screen with those helpers
15
15
 
16
16
  ## Interface
17
17
 
18
- - `bin/rails generate keystone:install` — sets up the host's Tailwind entry point
19
- and registers the gem's Stimulus controllers. Idempotent, and safe to re-run.
20
- - `KeystoneUi.configure` — a block yielding `accent` and `surface`, the names of
21
- the palettes the app wants (`:blue` and `:zinc` unless set).
18
+ - `bin/rails generate keystone:install` — sets up the host's Tailwind entry
19
+ point, registers the gem's Stimulus controllers, and adds the theme attributes
20
+ to the layout's `<html>` tag. Safe to re-run.
21
+ - `KeystoneUi.configure` — a block yielding the configuration: `accent` and
22
+ `surface` (palette names, `:blue` and `:zinc` unless set),
23
+ `theme_mode_supplier` (a callable that supplies a light, dark or system mode),
24
+ and the `tailwind_imports` and `tailwind_sources` lists (extra CSS files and
25
+ scan paths added to the Tailwind build).
22
26
 
23
27
  ## How to use it
24
28
 
25
29
  1. Confirm the prerequisites: Ruby >= 3.2 and **tailwindcss-rails v4+** in the
26
- host app. The gem brings ViewComponent with it. Tailwind does not have to be
27
- initialized first — the generator creates the stylesheet if it is missing.
30
+ host app. The gem brings ViewComponent and keystone_ui-styles with it.
31
+ Tailwind does not have to be initialized first, because the generator creates
32
+ the stylesheet if it is missing.
28
33
 
29
34
  2. Add the gem to the Gemfile and install it:
30
35
 
@@ -45,31 +50,45 @@ built on ViewComponent; hook it in before building any screen with those helpers
45
50
  bin/rails generate keystone:install
46
51
  ```
47
52
 
48
- It touches two host files:
53
+ It touches three host files:
49
54
 
50
- - `app/assets/tailwind/application.css` — creates it with
51
- `@import "tailwindcss";` and `@import "./keystone_source.css";` if absent;
52
- otherwise injects the Keystone import after the Tailwind one and strips
53
- superseded Keystone lines from older installs.
55
+ - `app/assets/tailwind/application.css` — if absent, it is created holding
56
+ `@import "tailwindcss";` and `@import "./keystone_source.css";`. If present,
57
+ the Keystone import is added on the line after `@import "tailwindcss";`, and
58
+ Keystone lines left by older installs are removed.
54
59
  - `app/javascript/controllers/index.js` — appends
55
60
  `import { registerControllers } from "keystone_ui/index"` and
56
61
  `registerControllers(application)`.
57
-
58
- Read its output. If it reports that `app/javascript/controllers/index.js` was
59
- not found, ask the developer where the Stimulus application is set up and add
60
- those two lines there — without them, dropdowns, modals, file uploads, the
61
- column picker and the other interactive components do nothing.
62
+ - `app/views/layouts/application.html.erb` — adds
63
+ `<%= keystone_theme_attributes %>` inside the `<html` tag, which writes the
64
+ light or dark mode onto the page.
65
+
66
+ Read its output for two warnings:
67
+
68
+ - `app/javascript/controllers/index.js not found` — ask the developer where
69
+ the Stimulus application is set up and add those two lines there. Without
70
+ them, dropdowns, modals, file uploads, the column picker, the theme toggle
71
+ and the other interactive components do nothing.
72
+ - `app/views/layouts/application.html.erb not found` — ask the developer which
73
+ layout the app renders and add `<%= keystone_theme_attributes %>` to its
74
+ `<html>` tag. Without it, a saved light or dark choice is not applied when
75
+ the page loads.
76
+
77
+ The CSS step only adds the Keystone import if `application.css` contains the
78
+ exact line `@import "tailwindcss";` followed by a line break. If the output
79
+ says nothing about the import and the line is not in the file, add
80
+ `@import "./keystone_source.css";` directly under the Tailwind import by hand.
62
81
 
63
82
  4. Restart the app (or rebuild assets). On boot the engine writes
64
- `app/assets/tailwind/keystone_source.css`, which points Tailwind at the
65
- component files and pulls in the gem's theme and component CSS. It is written
66
- only when `application.css` exists **and** contains
67
- `@import "./keystone_source.css";` — if the file never appears, that import is
68
- missing.
83
+ `app/assets/tailwind/keystone_source.css`, which imports the gem's theme and
84
+ component CSS and points Tailwind at the component files. It is written only
85
+ when `application.css` exists **and** contains
86
+ `@import "./keystone_source.css";`, so if the file never appears, that import
87
+ is missing.
69
88
 
70
89
  5. Keep the generated file out of git. It holds absolute paths to the gem on the
71
- machine that booted the app, and is rewritten on every boot — dev server, CI,
72
- and `assets:precompile` alike. Add to `.gitignore`:
90
+ machine that booted the app, and is rewritten on every boot, which includes
91
+ the dev server, CI and `assets:precompile`. Add to `.gitignore`:
73
92
 
74
93
  ```
75
94
  app/assets/tailwind/keystone_source.css
@@ -77,10 +96,10 @@ built on ViewComponent; hook it in before building any screen with those helpers
77
96
 
78
97
  Only the `@import` line in `application.css` belongs in the repo.
79
98
 
80
- 6. Settle the palette. Out of the box the accent scale is blue and the surface
81
- scale is zinc, with no configuration required. To change them statically, add
82
- an `@theme` block to `application.css` **after** the two imports and override
83
- only the shades the app uses:
99
+ 6. Settle the palette. Without configuration the accent scale is blue and the
100
+ surface scale is zinc. To change them statically, add an `@theme` block to
101
+ `application.css` **after** the two imports and override only the shades the
102
+ app uses:
84
103
 
85
104
  ```css
86
105
  @import "tailwindcss";
@@ -95,12 +114,14 @@ built on ViewComponent; hook it in before building any screen with those helpers
95
114
  Both scales run 50 through 950. Every component picks the values up with no
96
115
  component changes.
97
116
 
98
- This is a decision, not a default to assume: fixed app-wide colors set in CSS,
99
- or per-user colors generated at runtime by a companion theming gem. Ask the
100
- developer which the app wants before wiring either.
117
+ This is a decision to put to the developer: fixed app-wide colors set in CSS,
118
+ or per-user colors generated at runtime by a companion theming gem. Ask which
119
+ the app wants before wiring either.
101
120
 
102
- 7. Only if a companion gem or engine reads the palette choice, declare it in
103
- `config/initializers/keystone_ui.rb`:
121
+ 7. Write `config/initializers/keystone_ui.rb` only if one of the settings below
122
+ is wanted. Ask the developer about each rather than adding any by default.
123
+ The engine reads the configuration after initializers have run, so this file
124
+ is where the block goes.
104
125
 
105
126
  ```ruby
106
127
  require "keystone_ui"
@@ -108,30 +129,45 @@ built on ViewComponent; hook it in before building any screen with those helpers
108
129
  KeystoneUi.configure do |config|
109
130
  config.accent = :emerald
110
131
  config.surface = :slate
132
+ config.theme_mode_supplier = ->(view) { view.current_user&.theme }
133
+ config.tailwind_imports << "/absolute/path/to/extra.css"
134
+ config.tailwind_sources << "/absolute/path/to/components/**/*.{erb,rb}"
111
135
  end
112
136
  ```
113
137
 
114
- Keystone UI itself stores these names and renders from the CSS custom
115
- properties regardless — setting them changes no color on its own. Colors move
116
- when the custom properties in step 6 move. The engine reads host configuration
117
- after initializers have run, so the initializer is the correct home for the
118
- block.
138
+ - `accent` and `surface` — set these only when a companion gem or engine
139
+ reads the palette choice. Keystone UI stores the names and changes no color
140
+ from them, because colors come from the CSS custom properties in step 6.
141
+ - `theme_mode_supplier` — a callable that receives the view and returns
142
+ `"light"`, `"dark"` or `"system"`. It supplies the mode when the user has
143
+ not picked one with the theme toggle, since the `keystone_theme` cookie that
144
+ the toggle writes takes precedence. Any other return value, or no supplier,
145
+ falls back to light.
146
+ - `tailwind_imports` and `tailwind_sources` — lists to append to, never
147
+ assign. Each import becomes an `@import` line and each source becomes an
148
+ `@source` line in `keystone_source.css` on the next boot. They are for
149
+ another gem or engine whose CSS or templates must be in the same Tailwind
150
+ build, and that gem normally appends its own entries.
119
151
 
120
152
  ## Conventions
121
153
 
122
- - **Verify the install before building anything on it.** `application.css` holds
123
- both imports; `app/assets/tailwind/keystone_source.css` exists after a boot;
124
- the Stimulus setup calls `registerControllers(application)`. Then load one page
125
- that renders a `ui_*` helper and confirm it is styled and that an interactive
126
- piece (a dropdown, a dismissible alert) responds.
154
+ - **Verify the install before building anything on it.** Check four things:
155
+ `application.css` holds both imports, `app/assets/tailwind/keystone_source.css`
156
+ exists after a boot, the Stimulus setup calls `registerControllers(application)`,
157
+ and the layout's `<html>` tag contains `<%= keystone_theme_attributes %>`. Then
158
+ load one page that renders a `ui_*` helper and confirm it is styled and that an
159
+ interactive component (a dropdown, a dismissible alert) responds.
127
160
  - **Importmap is the supported JS path.** For apps configured with importmap the
128
- gem pins its own controllers — and the charting library they need — on boot, so
161
+ gem pins its own controllers, and the charting library they need, on boot, so
129
162
  the host pins nothing. If the app bundles JavaScript instead (esbuild, bun,
130
- webpack), the appended `keystone_ui/index` import has no pin behind it — surface
131
- this to the developer rather than guessing at a bundler configuration.
132
- - **Re-run the generator after upgrading the gem.** It clears out superseded
133
- install lines and reports "already up to date" when there is nothing to do.
163
+ webpack), the appended `keystone_ui/index` import has nothing pinned behind
164
+ it. Tell the developer rather than guessing at a bundler configuration.
165
+ - **Re-run the generator after upgrading the gem.** It removes superseded install
166
+ lines and reports that each file is already up to date when there is nothing
167
+ to do.
134
168
  - **New components need no re-run.** Tailwind rescans the gem on each build, so
135
169
  components added by a later version are styled on the next boot.
136
- - Building UI with the helpers — which helper to use, what keywords it takes — is
137
- out of scope here and belongs to `keystone_ui-develop`.
170
+ - On boot the engine deletes `app/assets/builds/tailwind/keystone_ui_engine.css`
171
+ if an older install left it there. Do not recreate it.
172
+ - Building UI with the helpers, including which helper to use and what keywords
173
+ it takes, is out of scope here and belongs to `keystone_ui-develop`.
@@ -51,6 +51,8 @@ develop:
51
51
  - ui_form
52
52
  - ui_file_upload
53
53
  - ui_funnel
54
+ - ui_bucket
55
+ - ui_bucket_series
54
56
  - ui_pipeline
55
57
  - ui_code
56
58
  - ui_disclosure
@@ -85,6 +87,8 @@ sources:
85
87
  - app/components/keystone/ui/form_field_component.rb
86
88
  - app/components/keystone/ui/form_page_component.rb
87
89
  - app/components/keystone/ui/funnel_component.rb
90
+ - app/components/keystone/ui/bucket_component.rb
91
+ - app/components/keystone/ui/bucket_series_component.rb
88
92
  - app/components/keystone/ui/grid_component.rb
89
93
  - app/components/keystone/ui/hero_component.rb
90
94
  - app/components/keystone/ui/input_component.rb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -81,6 +81,10 @@ files:
81
81
  - app/components/keystone/ui/bottom_nav_component.rb
82
82
  - app/components/keystone/ui/bottom_nav_item_component.html.erb
83
83
  - app/components/keystone/ui/bottom_nav_item_component.rb
84
+ - app/components/keystone/ui/bucket_component.html.erb
85
+ - app/components/keystone/ui/bucket_component.rb
86
+ - app/components/keystone/ui/bucket_series_component.html.erb
87
+ - app/components/keystone/ui/bucket_series_component.rb
84
88
  - app/components/keystone/ui/button_component.html.erb
85
89
  - app/components/keystone/ui/button_component.rb
86
90
  - app/components/keystone/ui/card_component.html.erb