From eae030884dbdaaadb65f63046180d76b13414921 Mon Sep 17 00:00:00 2001 From: tylercschneider Date: Thu, 15 Oct 2026 18:27:10 -0400 Subject: [PATCH 1/5] Show a stat card's note under its value Co-Authored-By: Claude Opus 5.5 --- .../keystone/ui/stat_card_component.html.erb | 1 + app/components/keystone/ui/stat_card_component.rb | 6 ++++-- test/render/stat_card_report_render_test.rb | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 test/render/stat_card_report_render_test.rb diff --git a/app/components/keystone/ui/stat_card_component.html.erb b/app/components/keystone/ui/stat_card_component.html.erb index 5ca97e6d..bfac681c 100644 --- a/app/components/keystone/ui/stat_card_component.html.erb +++ b/app/components/keystone/ui/stat_card_component.html.erb @@ -14,5 +14,6 @@

<% if link? %><%= value %><% else %><%= value %><% end %><% if suffix? %> <%= suffix %><% end %>

+ <% if note %>

<%= note %>

<% end %>

<%= change_label %>

diff --git a/app/components/keystone/ui/stat_card_component.rb b/app/components/keystone/ui/stat_card_component.rb index c7547390..228df5e7 100644 --- a/app/components/keystone/ui/stat_card_component.rb +++ b/app/components/keystone/ui/stat_card_component.rb @@ -13,6 +13,7 @@ class StatCardComponent < ViewComponent::Base CHANGE_ROW_CLASSES = "ks-stat-card-change h-5 text-sm" VALUE_LINK_CLASSES = "hover:underline focus:outline-none focus-visible:underline" INFO_BUTTON_CLASSES = "ks-stat-card-info peer shrink-0 transition" + NOTE_CLASSES = "ks-stat-card-note text-sm" INFO_ICON = <<~SVG.freeze @@ -26,10 +27,11 @@ class StatCardComponent < ViewComponent::Base info: "ks-tone-info" }.freeze - attr_reader :label, :value, :suffix, :definition, :calculation, :change, :href + attr_reader :label, :value, :suffix, :definition, :calculation, :change, :href, :note - def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil) + def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil, note: nil) @label = label + @note = note @value = value @variant = variant @suffix = suffix diff --git a/test/render/stat_card_report_render_test.rb b/test/render/stat_card_report_render_test.rb new file mode 100644 index 00000000..ca55bd2e --- /dev/null +++ b/test/render/stat_card_report_render_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "render_helper" + +class Keystone::Ui::StatCardReportRenderTest < ViewComponent::TestCase + def test_shows_a_note_under_its_value + page = render_inline(Keystone::Ui::StatCardComponent.new(label: "Commits", value: "553", note: "excluding merge commits")) + + assert_equal "excluding merge commits", page.css(".ks-stat-card-value + .ks-stat-card-note").text.strip + end +end From 9f760e464adcb4c78831942eefc1048d37cbd012 Mon Sep 17 00:00:00 2001 From: tylercschneider Date: Thu, 15 Oct 2026 18:27:27 -0400 Subject: [PATCH 2/5] Draw a stat card as a plain figure without its card border when asked Co-Authored-By: Claude Opus 5.5 --- app/components/keystone/ui/stat_card_component.rb | 6 ++++-- test/render/stat_card_report_render_test.rb | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/components/keystone/ui/stat_card_component.rb b/app/components/keystone/ui/stat_card_component.rb index 228df5e7..f505e617 100644 --- a/app/components/keystone/ui/stat_card_component.rb +++ b/app/components/keystone/ui/stat_card_component.rb @@ -6,6 +6,7 @@ class StatCardComponent < ViewComponent::Base EMPHASIS_CLASSES = "ks-stat-card-emphasis" HEADER_CLASSES = "ks-stat-card-header flex items-start justify-between" CARD_CLASSES = "ks-metric-card relative" + PLAIN_CLASSES = "ks-metric-plain relative" LABEL_CLASSES = "ks-stat-card-label text-sm" VALUE_BASE_CLASSES = "ks-stat-card-value text-3xl" SUFFIX_CLASSES = "ks-stat-card-suffix text-lg" @@ -29,9 +30,10 @@ class StatCardComponent < ViewComponent::Base attr_reader :label, :value, :suffix, :definition, :calculation, :change, :href, :note - def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil, note: nil) + def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil, note: nil, plain: false) @label = label @note = note + @plain = plain @value = value @variant = variant @suffix = suffix @@ -42,7 +44,7 @@ def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, end def classes - CARD_CLASSES + @plain ? PLAIN_CLASSES : CARD_CLASSES end def label_classes diff --git a/test/render/stat_card_report_render_test.rb b/test/render/stat_card_report_render_test.rb index ca55bd2e..55f748b4 100644 --- a/test/render/stat_card_report_render_test.rb +++ b/test/render/stat_card_report_render_test.rb @@ -8,4 +8,10 @@ def test_shows_a_note_under_its_value assert_equal "excluding merge commits", page.css(".ks-stat-card-value + .ks-stat-card-note").text.strip end + + def test_draws_a_plain_figure_without_the_card_border_when_asked + page = render_inline(Keystone::Ui::StatCardComponent.new(label: "Commits", value: "553", plain: true)) + + assert_equal [ true, false ], [ page.css(".ks-metric-plain").any?, page.css(".ks-metric-card").any? ] + end end From 87c3a1dc23d804947a9526ec7d91555165be5351 Mon Sep 17 00:00:00 2001 From: tylercschneider Date: Thu, 15 Oct 2026 18:27:49 -0400 Subject: [PATCH 3/5] Show a stat card's fallback, muted, when its value is missing Co-Authored-By: Claude Opus 5.5 --- .../keystone/ui/stat_card_component.html.erb | 2 +- app/components/keystone/ui/stat_card_component.rb | 12 +++++++++++- test/render/stat_card_report_render_test.rb | 6 ++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/components/keystone/ui/stat_card_component.html.erb b/app/components/keystone/ui/stat_card_component.html.erb index bfac681c..37a9cc19 100644 --- a/app/components/keystone/ui/stat_card_component.html.erb +++ b/app/components/keystone/ui/stat_card_component.html.erb @@ -12,7 +12,7 @@ <% end %>

- <% if link? %><%= value %><% else %><%= value %><% end %><% if suffix? %> <%= suffix %><% end %> + <% if missing? %><%= fallback %><% elsif link? %><%= value %><% else %><%= value %><% end %><% if suffix? %> <%= suffix %><% end %>

<% if note %>

<%= note %>

<% end %>

<%= change_label %>

diff --git a/app/components/keystone/ui/stat_card_component.rb b/app/components/keystone/ui/stat_card_component.rb index f505e617..0a010b21 100644 --- a/app/components/keystone/ui/stat_card_component.rb +++ b/app/components/keystone/ui/stat_card_component.rb @@ -15,6 +15,7 @@ class StatCardComponent < ViewComponent::Base VALUE_LINK_CLASSES = "hover:underline focus:outline-none focus-visible:underline" INFO_BUTTON_CLASSES = "ks-stat-card-info peer shrink-0 transition" NOTE_CLASSES = "ks-stat-card-note text-sm" + FALLBACK_CLASSES = "ks-stat-card-fallback" INFO_ICON = <<~SVG.freeze @@ -30,8 +31,9 @@ class StatCardComponent < ViewComponent::Base attr_reader :label, :value, :suffix, :definition, :calculation, :change, :href, :note - def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil, note: nil, plain: false) + def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil, note: nil, plain: false, fallback: nil) @label = label + @fallback = fallback @note = note @plain = plain @value = value @@ -43,6 +45,14 @@ def initialize(label:, value:, variant: :neutral, suffix: nil, definition: nil, @href = href end + def missing? + @value.to_s.empty? && !@fallback.nil? + end + + def fallback + @fallback + end + def classes @plain ? PLAIN_CLASSES : CARD_CLASSES end diff --git a/test/render/stat_card_report_render_test.rb b/test/render/stat_card_report_render_test.rb index 55f748b4..fcb931f6 100644 --- a/test/render/stat_card_report_render_test.rb +++ b/test/render/stat_card_report_render_test.rb @@ -14,4 +14,10 @@ def test_draws_a_plain_figure_without_the_card_border_when_asked assert_equal [ true, false ], [ page.css(".ks-metric-plain").any?, page.css(".ks-metric-card").any? ] end + + def test_shows_its_fallback_muted_when_its_value_is_missing + page = render_inline(Keystone::Ui::StatCardComponent.new(label: "Reviews", value: "", fallback: "N/A")) + + assert_equal "N/A", page.css(".ks-stat-card-value .ks-stat-card-fallback").text.strip + end end From e6dd334fe5c76f6265e3298c92f87559af6d9cd3 Mon Sep 17 00:00:00 2001 From: tylercschneider Date: Thu, 15 Oct 2026 18:28:07 -0400 Subject: [PATCH 4/5] Document the stat card's note, plain and fallback options Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ README.md | 3 +++ the_local/agents/keystone_ui-develop.md | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b50586ad..47021a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- `ui_stat_card` takes `note:` to show a short note under its value, `plain: true` to draw a plain figure with no card border or background, and `fallback:` to show muted text such as "N/A" when its value is missing, and a stat card given none of them looks as it did. + ## [0.48.0] - 2026-10-15 ### Changed diff --git a/README.md b/README.md index 9ed34cd8..3958ea9d 100644 --- a/README.md +++ b/README.md @@ -939,6 +939,9 @@ Renders a metric card for dashboards. - `calculation:` (String) — how the metric is computed - `change:` (Number) — percent change against the previous period, shown as ▲/▼ - `href:` (String) — turns the value into a link to that URL; the card itself stays unlinked +- `note:` (String) — a short note shown under the value +- `plain:` (Boolean, default `false`) — draws a plain figure with no card border or background, for a row of figures +- `fallback:` (String) — shown in a muted colour when the value is missing, such as `"N/A"` When `definition:` or `calculation:` is given, an info button appears. Hovering or focusing it shows the details in a panel floating below the card, and tapping it toggles the panel on touch screens. The card never changes size. diff --git a/the_local/agents/keystone_ui-develop.md b/the_local/agents/keystone_ui-develop.md index e5fe55ef..cab2aad5 100644 --- a/the_local/agents/keystone_ui-develop.md +++ b/the_local/agents/keystone_ui-develop.md @@ -420,7 +420,7 @@ two exceptions: `ui_page`'s `padding:` treats any value other than `:none` as 10. Explain figures. - - `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)` + - `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil, note: nil, plain: false, fallback: nil)` — `variant:` is `:neutral` `:success` `:danger` `:warning` `:info` and colours the value. `change:` is a signed number shown as `▲ 4.2%` in green, `▼` in red, or plain at zero. `href:` links the value only, so From 8c39078e45034c703bd287cb110e64b0fb868aaf Mon Sep 17 00:00:00 2001 From: tylercschneider Date: Thu, 15 Oct 2026 20:35:02 -0400 Subject: [PATCH 5/5] Require the keystone_ui-styles release with the stat note and plain classes Co-Authored-By: Claude Opus 5.5 --- keystone_ui.gemspec | 2 +- test/keystone_ui/packaging_test.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/keystone_ui.gemspec b/keystone_ui.gemspec index 67f74ab9..668c22e2 100644 --- a/keystone_ui.gemspec +++ b/keystone_ui.gemspec @@ -24,7 +24,7 @@ Gem::Specification.new do |spec| spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "the_local/**/*"] + [ "MIT-LICENSE" ] spec.require_paths = [ "lib" ] - spec.add_dependency "keystone_ui-styles", ">= 0.22.0" + spec.add_dependency "keystone_ui-styles", ">= 0.25.0" spec.add_dependency "view_component", ">= 2.0", "< 5" spec.post_install_message = <<~MSG diff --git a/test/keystone_ui/packaging_test.rb b/test/keystone_ui/packaging_test.rb index 5b0fd5bb..138653ca 100644 --- a/test/keystone_ui/packaging_test.rb +++ b/test/keystone_ui/packaging_test.rb @@ -88,6 +88,12 @@ def test_requires_a_keystone_ui_styles_with_the_bar_chart_classes refute requirement.satisfied_by?(Gem::Version.new("0.21.0")) end + def test_requires_a_keystone_ui_styles_with_the_stat_note_and_plain_classes + requirement = gemspec.dependencies.find { |d| d.name == "keystone_ui-styles" }.requirement + + refute requirement.satisfied_by?(Gem::Version.new("0.24.0")) + end + private def gemspec