plutonium 0.68.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/.claude/skills/plutonium-resource/SKILL.md +13 -0
  4. data/.ruby-version +1 -1
  5. data/CHANGELOG.md +37 -0
  6. data/CLAUDE.md +11 -0
  7. data/SECURITY.md +1 -1
  8. data/app/assets/plutonium.css +3 -1
  9. data/app/assets/plutonium.png +0 -0
  10. data/config/locales/en/async.yml +11 -1
  11. data/config/locales/en/ui.yml +3 -0
  12. data/docs/blog/introducing-plutonium-dashboards.md +4 -4
  13. data/docs/blog/the-slowest-query-is-the-count.md +109 -0
  14. data/docs/guides/performance.md +14 -0
  15. data/docs/public/favicon-16x16.png +0 -0
  16. data/docs/public/favicon-32x32.png +0 -0
  17. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  18. data/docs/public/images/blog/pagination-capped.png +0 -0
  19. data/docs/public/images/blog/pagination-countless.png +0 -0
  20. data/docs/public/images/components/avatar.png +0 -0
  21. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  22. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  23. data/docs/public/images/guides/dashboard-loading.png +0 -0
  24. data/docs/public/images/guides/dashboard-overview.png +0 -0
  25. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  26. data/docs/public/images/guides/kanban-after-move.png +0 -0
  27. data/docs/public/images/guides/kanban-board-light.png +0 -0
  28. data/docs/public/images/guides/kanban-board.png +0 -0
  29. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  30. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  31. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  32. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  33. data/docs/public/images/guides/nested-inputs.png +0 -0
  34. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  35. data/docs/public/images/guides/search-filtering-index.png +0 -0
  36. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  37. data/docs/public/images/guides/theming-after.png +0 -0
  38. data/docs/public/images/guides/theming-before.png +0 -0
  39. data/docs/public/images/guides/user-invites-landing.png +0 -0
  40. data/docs/public/images/guides/user-profile-edit.png +0 -0
  41. data/docs/public/images/guides/user-profile-show.png +0 -0
  42. data/docs/public/images/guides/wizards-chooser.png +0 -0
  43. data/docs/public/images/guides/wizards-completed.png +0 -0
  44. data/docs/public/images/guides/wizards-index-action.png +0 -0
  45. data/docs/public/images/guides/wizards-repeater.png +0 -0
  46. data/docs/public/images/guides/wizards-review.png +0 -0
  47. data/docs/public/images/guides/wizards-step.png +0 -0
  48. data/docs/public/images/home/tour-actions.png +0 -0
  49. data/docs/public/images/home/tour-async.png +0 -0
  50. data/docs/public/images/home/tour-kanban.png +0 -0
  51. data/docs/public/images/home/tour-tenancy.png +0 -0
  52. data/docs/public/images/home/tour-wizard.png +0 -0
  53. data/docs/public/images/home-index.png +0 -0
  54. data/docs/public/images/home-new.png +0 -0
  55. data/docs/public/images/home-show.png +0 -0
  56. data/docs/public/images/reference/async-progress-page.png +0 -0
  57. data/docs/public/images/reference/async-running-banner.png +0 -0
  58. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  59. data/docs/public/images/reference/structured-inputs.png +0 -0
  60. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  61. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  62. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  63. data/docs/public/images/tutorial/02-new-form.png +0 -0
  64. data/docs/public/images/tutorial/03-create-account.png +0 -0
  65. data/docs/public/images/tutorial/03-login.png +0 -0
  66. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  67. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  68. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  69. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  70. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  71. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  72. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  73. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  74. data/docs/public/og-image.png +0 -0
  75. data/docs/public/plutonium.png +0 -0
  76. data/docs/public/tutorial/plutonium-association-panel.png +0 -0
  77. data/docs/public/tutorial/plutonium-dashboard.png +0 -0
  78. data/docs/public/tutorial/plutonium-login-page.png +0 -0
  79. data/docs/public/tutorial/plutonium-nested-form.png +0 -0
  80. data/docs/public/tutorial/plutonium-posts-dashboard-customized.png +0 -0
  81. data/docs/public/tutorial/plutonium-posts-dashboard.png +0 -0
  82. data/docs/public/tutorial/plutonium-posts-detail-customized.png +0 -0
  83. data/docs/public/tutorial/plutonium-posts-detail.png +0 -0
  84. data/docs/public/tutorial/plutonium-publish-post.png +0 -0
  85. data/docs/reference/behavior/async-interactions.md +62 -5
  86. data/docs/reference/configuration.md +1 -0
  87. data/docs/reference/generators/lite.md +30 -6
  88. data/docs/reference/i18n.md +2 -2
  89. data/docs/reference/resource/definition.md +35 -0
  90. data/docs/reference/ui/layouts.md +13 -0
  91. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  92. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  93. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  94. data/gemfiles/rails_7.gemfile.lock +5 -5
  95. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  96. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  97. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  98. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  99. data/lib/plutonium/configuration.rb +17 -0
  100. data/lib/plutonium/definition/base.rb +22 -0
  101. data/lib/plutonium/definition/presentable.rb +4 -1
  102. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  103. data/lib/plutonium/interaction/async/context.rb +12 -2
  104. data/lib/plutonium/interaction/async/executor.rb +54 -0
  105. data/lib/plutonium/interaction/async/run.rb +128 -14
  106. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  107. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  108. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  109. data/lib/plutonium/pagination.rb +35 -0
  110. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +27 -1
  111. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  112. data/lib/plutonium/ui/grid/card.rb +11 -2
  113. data/lib/plutonium/ui/grid/resource.rb +2 -2
  114. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  115. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  116. data/lib/plutonium/ui/layout/base.rb +0 -9
  117. data/lib/plutonium/ui/layout/icon_rail.rb +42 -4
  118. data/lib/plutonium/ui/page/index.rb +23 -4
  119. data/lib/plutonium/ui/table/components/filter_pills.rb +8 -2
  120. data/lib/plutonium/ui/table/components/pagy_info.rb +30 -10
  121. data/lib/plutonium/ui/table/resource.rb +2 -2
  122. data/lib/plutonium/version.rb +1 -1
  123. data/package.json +1 -1
  124. data/plutonium.gemspec +3 -1
  125. data/src/css/components.css +12 -1
  126. data/src/css/font_awesome.css +2328 -0
  127. data/src/css/plutonium.css +1 -0
  128. metadata +11 -7
  129. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  130. data/docs/public/images/blog/dashboards-loading.png +0 -0
  131. data/docs/public/images/blog/dashboards-overview.png +0 -0
  132. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
@@ -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")
@@ -4,6 +4,11 @@ module Plutonium
4
4
  module UI
5
5
  module Page
6
6
  class Index < Base
7
+ # How many recently finished runs the running banner lists at most. It
8
+ # sits above the collection; a burst of dispatches must not push the
9
+ # collection off the screen. The run index has the rest.
10
+ RECENT_RUNS_LIMIT = 5
11
+
7
12
  # Cookie name carrying a per-resource view preference. Single
8
13
  # source of truth — Table::Resource, Grid::Resource, and the
9
14
  # Stimulus view-switcher controller all read from here. Underscored
@@ -101,12 +106,26 @@ module Plutonium
101
106
  # a URL for a route that doesn't exist here.
102
107
  return unless registered_resources.include?(Plutonium::Interaction::Async::Run)
103
108
 
104
- runs = authorized_resource_scope(
105
- Plutonium::Interaction::Async::Run,
106
- relation: Plutonium::Interaction::Async::Run.for_target(resource_class).in_progress
109
+ run_class = Plutonium::Interaction::Async::Run
110
+ in_progress = authorized_resource_scope(
111
+ run_class, relation: run_class.for_target(resource_class).in_progress
112
+ ).to_a
113
+
114
+ # The current user's own runs that settled moments ago. A dispatch
115
+ # sends them back here, and a short run (or one its pre_check rejected)
116
+ # finishes before this page renders, so "in progress" alone would show
117
+ # them nothing. Only their own: someone else's finished run is not news.
118
+ recent = authorized_resource_scope(
119
+ run_class,
120
+ relation: run_class.for_target(resource_class)
121
+ .where(initiator: current_user)
122
+ .finished_since(Plutonium.configuration.async_interactions.recent_runs_window.ago)
123
+ .order(finished_at: :desc)
124
+ .limit(RECENT_RUNS_LIMIT)
107
125
  ).to_a
108
126
 
109
- render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: runs)
127
+ # uniq: a run that finishes between the two queries is in both.
128
+ render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: (in_progress + recent).uniq)
110
129
  end
111
130
 
112
131
  def selected_view
@@ -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.68.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.68.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);
@@ -1222,6 +1224,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1222
1224
  margin-left: var(--pu-drag-marker-gap, 4px);
1223
1225
  }
1224
1226
 
1227
+ /* The failures on an async run's progress panel: an inline alert holding a
1228
+ list, so it sits flush in the card and the list keeps its bullets. */
1229
+ .pu-run-progress-failures {
1230
+ @apply mb-0 items-start;
1231
+ }
1232
+ .pu-run-progress-failures .pu-alert-message {
1233
+ @apply ms-0 ps-4 list-disc space-y-1;
1234
+ }
1235
+
1225
1236
  /* Indeterminate progress for an async run with no denominator (opaque work).
1226
1237
  A short segment sweeping the track: the motion is what says "alive", which a
1227
1238
  static bar at any width cannot, and a full-width bar would misread as done. */