plutonium 0.69.0 → 0.70.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +13 -0
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +17 -0
  5. data/CLAUDE.md +11 -0
  6. data/SECURITY.md +1 -1
  7. data/app/assets/plutonium.css +2 -2
  8. data/app/assets/plutonium.png +0 -0
  9. data/config/locales/en/ui.yml +3 -0
  10. data/docs/blog/introducing-plutonium-dashboards.md +4 -4
  11. data/docs/blog/the-slowest-query-is-the-count.md +109 -0
  12. data/docs/guides/performance.md +14 -0
  13. data/docs/public/favicon-16x16.png +0 -0
  14. data/docs/public/favicon-32x32.png +0 -0
  15. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  16. data/docs/public/images/blog/pagination-capped.png +0 -0
  17. data/docs/public/images/blog/pagination-countless.png +0 -0
  18. data/docs/public/images/components/avatar.png +0 -0
  19. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  20. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  21. data/docs/public/images/guides/dashboard-loading.png +0 -0
  22. data/docs/public/images/guides/dashboard-overview.png +0 -0
  23. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  24. data/docs/public/images/guides/kanban-after-move.png +0 -0
  25. data/docs/public/images/guides/kanban-board-light.png +0 -0
  26. data/docs/public/images/guides/kanban-board.png +0 -0
  27. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  28. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  29. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  30. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  31. data/docs/public/images/guides/nested-inputs.png +0 -0
  32. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  33. data/docs/public/images/guides/search-filtering-index.png +0 -0
  34. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  35. data/docs/public/images/guides/theming-after.png +0 -0
  36. data/docs/public/images/guides/theming-before.png +0 -0
  37. data/docs/public/images/guides/user-invites-landing.png +0 -0
  38. data/docs/public/images/guides/user-profile-edit.png +0 -0
  39. data/docs/public/images/guides/user-profile-show.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/public/images/home/tour-actions.png +0 -0
  47. data/docs/public/images/home/tour-async.png +0 -0
  48. data/docs/public/images/home/tour-kanban.png +0 -0
  49. data/docs/public/images/home/tour-tenancy.png +0 -0
  50. data/docs/public/images/home/tour-wizard.png +0 -0
  51. data/docs/public/images/home-index.png +0 -0
  52. data/docs/public/images/home-new.png +0 -0
  53. data/docs/public/images/home-show.png +0 -0
  54. data/docs/public/images/reference/async-progress-page.png +0 -0
  55. data/docs/public/images/reference/async-running-banner.png +0 -0
  56. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  57. data/docs/public/images/reference/structured-inputs.png +0 -0
  58. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  59. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  60. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  61. data/docs/public/images/tutorial/02-new-form.png +0 -0
  62. data/docs/public/images/tutorial/03-create-account.png +0 -0
  63. data/docs/public/images/tutorial/03-login.png +0 -0
  64. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  65. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  66. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  67. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  68. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  69. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  70. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  71. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  72. data/docs/public/og-image.png +0 -0
  73. data/docs/public/plutonium.png +0 -0
  74. data/docs/public/tutorial/plutonium-association-panel.png +0 -0
  75. data/docs/public/tutorial/plutonium-dashboard.png +0 -0
  76. data/docs/public/tutorial/plutonium-login-page.png +0 -0
  77. data/docs/public/tutorial/plutonium-nested-form.png +0 -0
  78. data/docs/public/tutorial/plutonium-posts-dashboard-customized.png +0 -0
  79. data/docs/public/tutorial/plutonium-posts-dashboard.png +0 -0
  80. data/docs/public/tutorial/plutonium-posts-detail-customized.png +0 -0
  81. data/docs/public/tutorial/plutonium-posts-detail.png +0 -0
  82. data/docs/public/tutorial/plutonium-publish-post.png +0 -0
  83. data/docs/reference/configuration.md +1 -0
  84. data/docs/reference/i18n.md +1 -1
  85. data/docs/reference/resource/definition.md +35 -0
  86. data/docs/reference/ui/layouts.md +13 -0
  87. data/gemfiles/rails_7.gemfile.lock +3 -3
  88. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  89. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  90. data/lib/plutonium/configuration.rb +17 -0
  91. data/lib/plutonium/definition/base.rb +22 -0
  92. data/lib/plutonium/pagination.rb +35 -0
  93. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +27 -1
  94. data/lib/plutonium/ui/grid/card.rb +11 -2
  95. data/lib/plutonium/ui/grid/resource.rb +2 -2
  96. data/lib/plutonium/ui/layout/icon_rail.rb +42 -4
  97. data/lib/plutonium/ui/table/components/filter_pills.rb +8 -2
  98. data/lib/plutonium/ui/table/components/pagy_info.rb +30 -10
  99. data/lib/plutonium/ui/table/resource.rb +2 -2
  100. data/lib/plutonium/version.rb +1 -1
  101. data/package.json +1 -1
  102. data/plutonium.gemspec +3 -1
  103. data/src/css/components.css +3 -1
  104. metadata +7 -7
  105. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  106. data/docs/public/images/blog/dashboards-loading.png +0 -0
  107. data/docs/public/images/blog/dashboards-overview.png +0 -0
  108. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
@@ -195,7 +195,10 @@ module Plutonium
195
195
  # - associations → display_name_of (label, not an object inspect)
196
196
  # - everything else → humanized, with the RAW value driving the
197
197
  # variant so status-like enums (in_progress, published…) still
198
- # resolve to a semantic color.
198
+ # resolve to a semantic color. A `colors:` map declared on the
199
+ # field's `column` (or else its `display`) picks the variant
200
+ # first, as it does for the Badge in the table and on the show
201
+ # page.
199
202
  # The variant hashes the stable formatted label for currency/associations,
200
203
  # so the decorative color no longer churns on an object's memory address.
201
204
  def render_meta_badge(name, value)
@@ -209,7 +212,7 @@ module Plutonium
209
212
  variant = badge.variant_for(label)
210
213
  else
211
214
  label = Plutonium::Translation.value_label(@record.class, name, value) || badge.humanize(value)
212
- variant = badge.variant_for(value)
215
+ variant = badge.variant_for(value, colors: declared_colors(name))
213
216
  end
214
217
 
215
218
  span(class: tokens("pu-badge", "pu-badge-#{variant}")) do
@@ -217,6 +220,12 @@ module Plutonium
217
220
  end
218
221
  end
219
222
 
223
+ def declared_colors(name)
224
+ name = name.to_sym
225
+ resource_definition.defined_columns.dig(name, :options, :colors) ||
226
+ resource_definition.defined_displays.dig(name, :options, :colors)
227
+ end
228
+
220
229
  def currency_field?(name)
221
230
  klass = record.class
222
231
  klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(name.to_sym)
@@ -54,7 +54,7 @@ module Plutonium
54
54
  end
55
55
 
56
56
  def render_filter_pills
57
- TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
57
+ TableFilterPills(query: current_query_object, total_count: pagy_instance&.count, count_capped: pagy_instance&.options&.fetch(:count_capped, false))
58
58
  end
59
59
 
60
60
  def render_bulk_actions_toolbar
@@ -181,7 +181,7 @@ module Plutonium
181
181
 
182
182
  def render_footer
183
183
  div(class: "lg:sticky bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-[var(--pu-body)]") {
184
- TableInfo(pagy_instance, item_name: resource_name(resource_class, pagy_instance.count))
184
+ TableInfo(pagy_instance, item_name: resource_name(resource_class, pagy_instance.count || 2))
185
185
  TablePagination(pagy_instance)
186
186
  }
187
187
  end
@@ -18,6 +18,12 @@ module Plutonium
18
18
  # render IconRail.new(menu: @menu) do |rail|
19
19
  # rail.with_brand { image_tag("logo.svg", class: "w-8 h-8") }
20
20
  # end
21
+ #
22
+ # @example Links pinned above the footer, outside the scrolling nav
23
+ # render IconRail.new(
24
+ # menu: @menu,
25
+ # footer_menu: Phlexi::Menu::Builder.new { |m| m.item "Settings", url: settings_path, icon: Phlex::TablerIcons::Settings }
26
+ # )
21
27
  class IconRail < Plutonium::UI::Component::Base
22
28
  include Phlex::Slotable
23
29
 
@@ -28,9 +34,11 @@ module Plutonium
28
34
  DEFAULT_MAX_DEPTH = 2
29
35
 
30
36
  # @param menu [Phlexi::Menu::Builder, nil] Menu structure (same shape as SidebarMenu)
37
+ # @param footer_menu [Phlexi::Menu::Builder, nil] Links pinned above the footer, outside the scrolling nav
31
38
  # @param max_depth [Integer] Maximum rendering depth (depth 2 supports parent+children)
32
- def initialize(menu: nil, max_depth: DEFAULT_MAX_DEPTH)
39
+ def initialize(menu: nil, footer_menu: nil, max_depth: DEFAULT_MAX_DEPTH)
33
40
  @menu = menu
41
+ @footer_menu = footer_menu
34
42
  @max_depth = max_depth
35
43
  end
36
44
 
@@ -46,6 +54,7 @@ module Plutonium
46
54
  ) do
47
55
  render_brand_section
48
56
  render_nav_section
57
+ render_footer_menu_section
49
58
  render_footer_section
50
59
  end
51
60
  end
@@ -72,6 +81,16 @@ module Plutonium
72
81
  end
73
82
  end
74
83
 
84
+ # Kept ahead of the footer, which stays the rail's last section: the
85
+ # pinned rail's styles right-align that section to place the pin button.
86
+ def render_footer_menu_section
87
+ return if @footer_menu.nil? || @footer_menu.items.empty?
88
+
89
+ div(class: "flex flex-col items-center gap-1 px-2 py-2 border-t border-[var(--pu-border)] shrink-0") do
90
+ render_items(@footer_menu.items, 0)
91
+ end
92
+ end
93
+
75
94
  def render_footer_section
76
95
  div(class: "h-14 flex items-center justify-center border-t border-[var(--pu-border)] shrink-0") do
77
96
  render_pin_button
@@ -116,11 +135,12 @@ module Plutonium
116
135
  def render_leaf_item(item, depth)
117
136
  a(
118
137
  href: item.url,
119
- title: item.label,
120
- **item_link_attributes(item, "icon-rail-leaf #{leaf_classes(item, depth)}", base_aria: {label: item.label})
138
+ title: item_title(item),
139
+ **item_link_attributes(item, "icon-rail-leaf #{leaf_classes(item, depth)}", base_aria: {label: item_title(item)})
121
140
  ) do
122
141
  render_item_icon(item)
123
142
  span(class: "icon-rail-label hidden") { item.label }
143
+ render_trailing_badge(item, "icon-rail-label hidden")
124
144
  end
125
145
  end
126
146
 
@@ -165,14 +185,32 @@ module Plutonium
165
185
  a(
166
186
  href: child.url,
167
187
  role: "menuitem",
188
+ title: item_title(child),
168
189
  **item_link_attributes(child, "icon-rail-flyout-item")
169
- ) { child.label }
190
+ ) do
191
+ plain child.label
192
+ render_trailing_badge(child)
193
+ end
170
194
  end
171
195
  end
172
196
  end
173
197
  end
174
198
  end
175
199
 
200
+ # The label, with the item's trailing badge in brackets, for tooltips
201
+ # and screen readers: "Reports (Pro)".
202
+ def item_title(item)
203
+ item.trailing_badge ? "#{item.label} (#{item.trailing_badge})" : item.label
204
+ end
205
+
206
+ # A short marker after the label, such as the plan that unlocks a page.
207
+ def render_trailing_badge(item, extra_classes = nil)
208
+ return unless item.trailing_badge
209
+
210
+ span(class: ["icon-rail-badge ml-auto rounded px-1.5 py-0.5 text-[10px] font-medium leading-none " \
211
+ "bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]", extra_classes].compact.join(" ")) { item.trailing_badge }
212
+ end
213
+
176
214
  def render_item_icon(item)
177
215
  if item.icon
178
216
  render item.icon.new(class: "w-5 h-5 shrink-0")
@@ -5,9 +5,14 @@ module Plutonium
5
5
  module Table
6
6
  module Components
7
7
  class FilterPills < Plutonium::UI::Component::Base
8
- def initialize(query:, total_count: nil)
8
+ # @param total_count [Integer, nil] nil hides the count (countless
9
+ # pagination, `pagination count: false`)
10
+ # @param count_capped [Boolean] the count stopped at its cap
11
+ # (`pagination count: 10_000`), so it reads "10000+ results"
12
+ def initialize(query:, total_count: nil, count_capped: false)
9
13
  @query = query
10
14
  @total_count = total_count
15
+ @count_capped = count_capped
11
16
  end
12
17
 
13
18
  def view_template
@@ -79,7 +84,8 @@ module Plutonium
79
84
 
80
85
  def render_result_count
81
86
  div(class: "ml-auto text-xs text-[var(--pu-text-muted)]") do
82
- plain t("plutonium.ui.table.result_count", count: @total_count)
87
+ key = @count_capped ? "plutonium.ui.table.result_count_capped" : "plutonium.ui.table.result_count"
88
+ plain t(key, count: @total_count)
83
89
  end
84
90
  end
85
91
  end
@@ -34,21 +34,41 @@ module Plutonium
34
34
  private
35
35
 
36
36
  def results_info
37
- key = if @pagy.count.zero?
38
- "pagy.info_tag.no_items"
39
- elsif @pagy.in == @pagy.count
40
- "pagy.info_tag.single_page"
41
- else
42
- "pagy.info_tag.multiple_pages"
43
- end
44
-
45
37
  div do
46
- render_template(pagy_template(key, @pagy.count)) do |placeholder|
47
- b { @pagy.public_send(placeholder).to_s }
38
+ render_template(results_template) do |placeholder|
39
+ b { (placeholder == :count) ? count_label : @pagy.public_send(placeholder).to_s }
48
40
  end
49
41
  end
50
42
  end
51
43
 
44
+ def results_template
45
+ count = known_count
46
+ if count.nil?
47
+ # Countless, past page 1: the total is unknown, so no "of N".
48
+ t("plutonium.ui.table.page_range", item_name: item_name(2), from: "%{from}", to: "%{to}")
49
+ elsif count.zero?
50
+ pagy_template("pagy.info_tag.no_items", count)
51
+ elsif count == @pagy.in
52
+ pagy_template("pagy.info_tag.single_page", count)
53
+ else
54
+ pagy_template("pagy.info_tag.multiple_pages", count)
55
+ end
56
+ end
57
+
58
+ # The total, when it is known. A countless pager (`pagination count:
59
+ # false`) has none, except on a first page with no next page, where the
60
+ # rows on it are the whole set.
61
+ def known_count
62
+ return @pagy.count if @pagy.count
63
+ @pagy.in if @pagy.page == 1 && !@pagy.next
64
+ end
65
+
66
+ # "10000+" when the count hit its cap (`pagination count: 10_000`).
67
+ def count_label
68
+ count = known_count.to_s
69
+ @pagy.options[:count_capped] ? t("plutonium.ui.table.capped_count", count:) : count
70
+ end
71
+
52
72
  def per_page_selector
53
73
  id = "perPage#{SecureRandom.hex}"
54
74
 
@@ -71,7 +71,7 @@ module Plutonium
71
71
  end
72
72
 
73
73
  def render_filter_pills
74
- TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
74
+ TableFilterPills(query: current_query_object, total_count: pagy_instance&.count, count_capped: pagy_instance&.options&.fetch(:count_capped, false))
75
75
  end
76
76
 
77
77
  def current_search_url
@@ -315,7 +315,7 @@ module Plutonium
315
315
 
316
316
  def render_footer
317
317
  div(class: "lg:sticky bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-[var(--pu-body)]") {
318
- TableInfo(pagy_instance, item_name: resource_name(resource_class, pagy_instance.count))
318
+ TableInfo(pagy_instance, item_name: resource_name(resource_class, pagy_instance.count || 2))
319
319
  TablePagination(pagy_instance)
320
320
  }
321
321
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.69.0"
2
+ VERSION = "0.70.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
data/plutonium.gemspec CHANGED
@@ -35,7 +35,9 @@ Gem::Specification.new do |spec|
35
35
  f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) ||
36
36
  # Maintainer tooling; host apps read tailwind.options.js and the postcss
37
37
  # plugin from the gem, not the lockfile or yarn settings.
38
- %w[.yarnrc.yml yarn.lock public/plutonium-assets/].any? { |prefix| f.start_with?(prefix) }
38
+ %w[.yarnrc.yml yarn.lock public/plutonium-assets/].any? { |prefix| f.start_with?(prefix) } ||
39
+ # Full-size brand sources; the gem ships the resized app/assets copy.
40
+ f.start_with?("brand/")
39
41
  end
40
42
  end
41
43
  spec.bindir = "exe"
@@ -918,7 +918,9 @@ html.pu-rail-pinned .icon-rail-pin-expand {
918
918
  }
919
919
 
920
920
  .icon-rail-flyout-item {
921
- display: block;
921
+ display: flex;
922
+ align-items: center;
923
+ gap: 8px;
922
924
  padding: 7px 10px;
923
925
  font-size: 13px;
924
926
  color: var(--pu-text);
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.69.0
4
+ version: 0.70.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-15 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -619,6 +619,7 @@ files:
619
619
  - docs/blog/jobs-are-not-permission-snapshots.md
620
620
  - docs/blog/plutonium-and-ai-agents.md
621
621
  - docs/blog/realtime-is-one-line-and-four-dependencies.md
622
+ - docs/blog/the-slowest-query-is-the-count.md
622
623
  - docs/blog/two-forms-one-dom-id.md
623
624
  - docs/blog/whats-new-async-kanban-wizards.md
624
625
  - docs/getting-started/index.md
@@ -660,11 +661,9 @@ files:
660
661
  - docs/public/favicon-16x16.png
661
662
  - docs/public/favicon-32x32.png
662
663
  - docs/public/favicon.ico
663
- - docs/public/images/blog/dashboards-card-error.png
664
- - docs/public/images/blog/dashboards-loading.png
665
- - docs/public/images/blog/dashboards-overview.png
666
- - docs/public/images/blog/dashboards-sidebar.png
667
664
  - docs/public/images/blog/i18n-es-admin.png
665
+ - docs/public/images/blog/pagination-capped.png
666
+ - docs/public/images/blog/pagination-countless.png
668
667
  - docs/public/images/components/avatar.png
669
668
  - docs/public/images/guides/custom-actions-bulk.png
670
669
  - docs/public/images/guides/dashboard-card-error.png
@@ -1225,6 +1224,7 @@ files:
1225
1224
  - lib/plutonium/migrations.rb
1226
1225
  - lib/plutonium/models/has_cents.rb
1227
1226
  - lib/plutonium/package/engine.rb
1227
+ - lib/plutonium/pagination.rb
1228
1228
  - lib/plutonium/portal/controller.rb
1229
1229
  - lib/plutonium/portal/dynamic_controllers.rb
1230
1230
  - lib/plutonium/portal/engine.rb
@@ -1584,7 +1584,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
1584
1584
  - !ruby/object:Gem::Version
1585
1585
  version: '0'
1586
1586
  requirements: []
1587
- rubygems_version: 3.6.2
1587
+ rubygems_version: 4.0.20
1588
1588
  specification_version: 4
1589
1589
  summary: Build production-ready Rails apps in minutes, not days
1590
1590
  test_files: []