plutonium 0.67.0 → 0.69.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  4. data/.claude/skills/plutonium-resource/SKILL.md +3 -2
  5. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  6. data/CHANGELOG.md +33 -0
  7. data/SECURITY.md +1 -1
  8. data/app/assets/plutonium.css +3 -1
  9. data/config/locales/en/async.yml +11 -1
  10. data/config/locales/en/dashboard.yml +1 -0
  11. data/config/locales/en/ui.yml +3 -0
  12. data/docs/public/images/reference/async-progress-page.png +0 -0
  13. data/docs/public/images/reference/async-running-banner.png +0 -0
  14. data/docs/reference/behavior/async-interactions.md +62 -5
  15. data/docs/reference/dashboard/dsl.md +54 -3
  16. data/docs/reference/generators/lite.md +30 -6
  17. data/docs/reference/i18n.md +1 -1
  18. data/docs/reference/resource/definition.md +2 -0
  19. data/docs/reference/ui/forms.md +23 -0
  20. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  21. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  22. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  23. data/gemfiles/rails_7.gemfile.lock +5 -5
  24. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  25. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  26. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  27. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  28. data/lib/plutonium/dashboard/base.rb +80 -1
  29. data/lib/plutonium/dashboard/card.rb +12 -13
  30. data/lib/plutonium/dashboard/controller.rb +28 -10
  31. data/lib/plutonium/dashboard/dsl.rb +59 -2
  32. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  33. data/lib/plutonium/dashboard/filter.rb +114 -0
  34. data/lib/plutonium/dashboard/link.rb +35 -0
  35. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  36. data/lib/plutonium/definition/presentable.rb +4 -1
  37. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  38. data/lib/plutonium/interaction/async/context.rb +12 -2
  39. data/lib/plutonium/interaction/async/executor.rb +54 -0
  40. data/lib/plutonium/interaction/async/run.rb +128 -14
  41. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  42. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  43. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  44. data/lib/plutonium/resource/query_object.rb +6 -0
  45. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  46. data/lib/plutonium/ui/avatar.rb +2 -2
  47. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  48. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  49. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  50. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  51. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  52. data/lib/plutonium/ui/form/base.rb +20 -0
  53. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  54. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  55. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  56. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  57. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  58. data/lib/plutonium/ui/form/theme.rb +15 -1
  59. data/lib/plutonium/ui/grid/card.rb +12 -4
  60. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  61. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  62. data/lib/plutonium/ui/layout/base.rb +0 -9
  63. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  64. data/lib/plutonium/ui/page/index.rb +23 -4
  65. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  66. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/plutonium.gemspec +1 -1
  70. data/src/css/components.css +9 -0
  71. data/src/css/font_awesome.css +2328 -0
  72. data/src/css/plutonium.css +1 -0
  73. metadata +16 -4
@@ -87,14 +87,25 @@ module Plutonium
87
87
  uri.to_s
88
88
  end
89
89
 
90
+ # Capped at `choice_limit`, plus the records the field holds now:
91
+ # one past the cap or outside the viewer's scope would otherwise not
92
+ # render, and saving the form would drop it (a select falls back to
93
+ # its blank option, an unrendered checkbox is not submitted).
90
94
  def choices
91
95
  @choices ||= begin
92
96
  collection = @raw_choices || authorized_relation
93
- collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
97
+ if collection.is_a?(ActiveRecord::Relation)
98
+ collection = collection.limit(@choice_limit) if @choice_limit
99
+ collection = (collection.to_a + selected_records).uniq
100
+ end
94
101
  build_choice_mapper(collection)
95
102
  end
96
103
  end
97
104
 
105
+ def selected_records
106
+ @selected_records ||= Array(field.object.public_send(association_reflection.name))
107
+ end
108
+
98
109
  # Builds the authorized association relation. Shared by `choices`
99
110
  # (which then applies `choice_limit`) and `normalize_simple_input`
100
111
  # (which validates against the full set so typeahead picks beyond
@@ -106,8 +117,11 @@ module Plutonium
106
117
  authorized_resource_scope(klass, relation: relation)
107
118
  end
108
119
 
120
+ # AssociationBase#build_attributes already calls
121
+ # build_association_attributes. Calling it here too ran it twice, and
122
+ # the second pass re-read the keys the first had deleted, silently
123
+ # dropping `skip_authorization:` and `add_action:`.
109
124
  def build_attributes
110
- build_association_attributes
111
125
  super
112
126
  # Stash; the URL helper needs view_context which only exists
113
127
  # once we're rendering.
@@ -163,8 +177,16 @@ module Plutonium
163
177
  # not include records reachable via typeahead). For explicit
164
178
  # `@raw_choices`, fall back to membership in the rendered list.
165
179
  def normalize_simple_input(input_value)
180
+ # An explicit blank (the select's blank option, a "None" radio)
181
+ # clears a singular association: "" survives resource_params'
182
+ # `compact` and `<name>_sgid = ""` locates nil. A field that was not
183
+ # submitted arrives as nil and is left alone.
184
+ return "" if input_value == "" && !attributes[:multiple]
185
+
166
186
  sgid = SignedGlobalID.parse(input_value.presence)
167
187
  return nil unless sgid
188
+ # Keeping what the field already holds is not a new grant.
189
+ return sgid if selected_records.map(&:to_global_id).include?(sgid)
168
190
 
169
191
  if @raw_choices
170
192
  @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
@@ -177,13 +199,14 @@ module Plutonium
177
199
  nil
178
200
  end
179
201
 
202
+ # Compared as URI strings: URI objects don't match by value in a Set.
180
203
  def selected?(option)
181
- case association_reflection.macro
182
- when :belongs_to, :has_one
183
- singular_field_value == SignedGlobalID.parse(option)
184
- when :has_many, :has_and_belongs_to_many
185
- collection_field_value.any? { |item| item == SignedGlobalID.parse(option) }
186
- end
204
+ selected_gids.include?(SignedGlobalID.parse(option)&.uri&.to_s)
205
+ end
206
+
207
+ def selected_gids
208
+ @selected_gids ||= Array(attributes[:multiple] ? collection_field_value : singular_field_value)
209
+ .compact.to_set { |sgid| sgid.uri.to_s }
187
210
  end
188
211
 
189
212
  def singular_field_value
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A has_many / habtm association rendered as one checkbox per authorized
8
+ # record (`input :tags, as: :association_checkboxes`).
9
+ class SecureAssociationCheckboxes < SecureAssociationChoices
10
+ protected
11
+
12
+ def control_type = :checkbox
13
+
14
+ def control_theme = :association_checkbox
15
+
16
+ def supported_macros = %i[has_many has_and_belongs_to_many]
17
+
18
+ # Unchecked boxes are not submitted, so without this blank value
19
+ # clearing every box would leave the param out entirely and keep the
20
+ # existing records. The blank is dropped by normalize_array_input.
21
+ # Disabled with the list: a disabled list submits no boxes, so a live
22
+ # blank would save as "no records" and wipe the association.
23
+ def render_hidden_input
24
+ input(type: :hidden, name: attributes[:name], value: "", autocomplete: "off", hidden: true, disabled: attributes[:disabled])
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Abstract base for the list-style association inputs: every choice is
8
+ # rendered as its own control (checkbox, toggle or radio button) instead
9
+ # of an option inside a slim-select.
10
+ #
11
+ # Everything that makes the association input "secure" is inherited from
12
+ # {SecureAssociation} unchanged: the choices come from the policy-scoped
13
+ # relation (or `choices:`), each value is a signed global id, submitted
14
+ # ids are validated against the authorized relation, and the inline "+"
15
+ # add button still renders. Only the markup differs.
16
+ #
17
+ # Subclasses declare the control's input type, the macros they accept and
18
+ # the theme key their controls style off.
19
+ class SecureAssociationChoices < SecureAssociation
20
+ def view_template
21
+ div(class: "flex items-start space-x-1") do
22
+ # The wrapper's <label for=> points at this id, but a label can't
23
+ # name a div, so the group carries the field label itself.
24
+ div(**group_attributes) do
25
+ render_hidden_input
26
+ render_blank_choice if include_blank?
27
+ render_choices
28
+ end
29
+ render_add_button
30
+ end
31
+ end
32
+
33
+ protected
34
+
35
+ # The type attribute of each control: "checkbox" or "radio".
36
+ def control_type = raise(NotImplementedError, "#{self.class}#control_type")
37
+
38
+ # Theme key the individual controls style off.
39
+ def control_theme = raise(NotImplementedError, "#{self.class}#control_theme")
40
+
41
+ # Association macros this variant can represent.
42
+ def supported_macros = raise(NotImplementedError, "#{self.class}#supported_macros")
43
+
44
+ def group_role = "group"
45
+
46
+ # Everything the caller passed (data-*, aria-*, ...) lands on the
47
+ # group; what belongs to a form control goes on each choice instead.
48
+ CONTROL_ATTRIBUTES = %i[name required disabled multiple size autofocus form value].freeze
49
+
50
+ def group_attributes
51
+ mix(attributes.except(*CONTROL_ATTRIBUTES), {role: group_role, aria: {label: field.label}})
52
+ end
53
+
54
+ def build_association_attributes
55
+ super
56
+
57
+ unless supported_macros.include?(association_reflection.macro)
58
+ raise ArgumentError,
59
+ "#{self.class.name.demodulize} does not support #{association_reflection.macro} " \
60
+ "associations (#{association_name}). Supported: #{supported_macros.join(", ")}."
61
+ end
62
+
63
+ if association_reflection.polymorphic?
64
+ raise ArgumentError, "#{self.class.name.demodulize} does not support polymorphic associations (#{association_name})."
65
+ end
66
+ end
67
+
68
+ def association_name = "#{field.object.class.name}##{association_reflection.name}"
69
+
70
+ def build_attributes
71
+ super
72
+ if @group_method
73
+ raise ArgumentError, "#{self.class.name.demodulize} does not support group_method: (#{association_name})."
74
+ end
75
+
76
+ # Typeahead drives a slim-select dropdown; a rendered list has no
77
+ # search box to wire it to.
78
+ @typeahead_option = false
79
+ end
80
+
81
+ def render_choices
82
+ if choices.values.empty?
83
+ p(class: "pu-hint") { Plutonium::Translation.t("plutonium.ui.form.association_choices.empty") }
84
+ return
85
+ end
86
+
87
+ choices.each.with_index do |(value, text), index|
88
+ render_choice(value:, text:, id: "#{attributes[:id]}_#{index}", checked: selected?(value), autofocus: index.zero? && attributes[:autofocus])
89
+ end
90
+ end
91
+
92
+ def render_choice(value:, text:, id:, checked:, autofocus: false)
93
+ label(for: id, class: themed(:association_choice_label, field)) do
94
+ input(
95
+ type: control_type,
96
+ id:,
97
+ name: attributes[:name],
98
+ value:,
99
+ checked:,
100
+ required: control_required?,
101
+ disabled: attributes[:disabled],
102
+ form: attributes[:form],
103
+ autofocus:,
104
+ class: themed(control_theme, field)
105
+ )
106
+ span { text }
107
+ end
108
+ end
109
+
110
+ def render_hidden_input
111
+ end
112
+
113
+ def render_blank_choice
114
+ end
115
+
116
+ def include_blank? = false
117
+
118
+ def control_required? = false
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A belongs_to / has_one association rendered as one radio button per
8
+ # authorized record (`input :category, as: :association_radio_buttons`).
9
+ #
10
+ # An optional association also gets a blank "None" choice, since a
11
+ # radio group can't otherwise be cleared once something is picked.
12
+ # `include_blank: false` drops it. A required association never gets
13
+ # one: a checked "None" would satisfy the group's `required`.
14
+ class SecureAssociationRadioButtons < SecureAssociationChoices
15
+ protected
16
+
17
+ def control_type = :radio
18
+
19
+ def control_theme = :association_radio_button
20
+
21
+ def supported_macros = %i[belongs_to has_one]
22
+
23
+ def group_role = "radiogroup"
24
+
25
+ def include_blank?
26
+ return false if attributes[:required]
27
+
28
+ @include_blank != false
29
+ end
30
+
31
+ # `required` on any radio in a group makes picking one of them required.
32
+ def control_required? = attributes[:required]
33
+
34
+ def render_blank_choice
35
+ render_choice(value: "", text: blank_choice_text, id: "#{attributes[:id]}_blank", checked: singular_field_value.nil?)
36
+ end
37
+
38
+ def blank_choice_text
39
+ field.placeholder.presence || Plutonium::Translation.t("plutonium.ui.form.association_choices.none")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Switch-styled {SecureAssociationCheckboxes}
8
+ # (`input :tags, as: :association_toggles`). Same behavior; only the
9
+ # `.pu-toggle` styling differs.
10
+ class SecureAssociationToggles < SecureAssociationCheckboxes
11
+ protected
12
+
13
+ def control_theme = :association_toggle
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -149,7 +149,21 @@ module Plutonium
149
149
  polymorphic_association: :association,
150
150
  valid_polymorphic_association: :valid_association,
151
151
  invalid_polymorphic_association: :invalid_association,
152
- neutral_polymorphic_association: :neutral_association
152
+ neutral_polymorphic_association: :neutral_association,
153
+
154
+ # List-style association variants (`as: :association_checkboxes`,
155
+ # `:association_toggles`, `:association_radio_buttons`). The bare key
156
+ # styles the list; `association_<control>` styles each control.
157
+ association_checkboxes: "flex flex-col gap-2",
158
+ association_toggles: "flex flex-col gap-2",
159
+ association_radio_buttons: "flex flex-col gap-2",
160
+ association_choice_label: "inline-flex items-center gap-2 text-sm text-[var(--pu-text)] cursor-pointer",
161
+ association_checkbox: :checkbox,
162
+ invalid_association_checkbox: :invalid_boolean,
163
+ association_toggle: :toggle,
164
+ invalid_association_toggle: :invalid_toggle,
165
+ association_radio_button: :radio_button,
166
+ invalid_association_radio_button: "pu-radio pu-input-invalid"
153
167
  })
154
168
  end
155
169
  end
@@ -105,13 +105,21 @@ module Plutonium
105
105
  value = field_value(slots[:image])
106
106
 
107
107
  if size == :cover
108
- # Cover is a full-width banner, not an avatar: only render when an
109
- # actual image resolves (no deterministic fallback).
108
+ # Cover is a full-width banner, not an avatar, so a record with no
109
+ # image gets no generated stand-in. It still gets the banner's
110
+ # space, as an empty slot elsewhere gets its em-dash: without it the
111
+ # card is a banner shorter than its neighbours, and the row stretches
112
+ # it into a block of blank card under its text.
110
113
  src = Plutonium::UI::Avatar.resolve_image_src(value, helpers)
111
- return unless src
112
114
 
113
115
  div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
114
- img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
116
+ if src
117
+ img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
118
+ else
119
+ div(class: "w-full h-full flex items-center justify-center text-[var(--pu-text-subtle)]", aria_hidden: "true") do
120
+ render Phlex::TablerIcons::Photo.new(class: "w-8 h-8")
121
+ end
122
+ end
115
123
  end
116
124
  else
117
125
  # Small avatar slot: render the resolved image, or Avatar's generic
@@ -43,6 +43,8 @@ module Plutonium
43
43
  "running" => "info",
44
44
  "completed" => "success",
45
45
  "completed_with_errors" => "warning",
46
+ # The input was refused, nothing broke: see Run#rejected?.
47
+ "rejected" => "warning",
46
48
  "failed" => "danger"
47
49
  }.freeze
48
50
 
@@ -83,7 +85,9 @@ module Plutonium
83
85
  div(class: "pu-card pu-run-progress", **poll_attributes) do
84
86
  div(class: "pu-card-body space-y-4") do
85
87
  render_status
86
- render_progress
88
+ # A rejected run never started: an empty progress line beside
89
+ # "Nothing was processed" would say the same thing twice.
90
+ run.rejected? ? render_rejected : render_progress
87
91
  render_failures if run.errors_log.any?
88
92
  end
89
93
  end
@@ -123,15 +127,24 @@ module Plutonium
123
127
  # The count rides alongside the badge rather than only in the list
124
128
  # below: a partially-applied :continue run ends as "completed", and
125
129
  # the one thing a reader must not be able to miss is that it did not
126
- # do everything it was asked to.
130
+ # do everything it was asked to. Muted, because the alert below
131
+ # carries the colour (danger or warning) for what the problems mean.
127
132
  if run.error_count.positive?
128
- span(class: "text-xs font-medium text-[var(--pu-text-danger)]") do
133
+ span(class: "text-xs font-medium text-[var(--pu-text-muted)]") do
129
134
  plain t("plutonium.async.progress.error_count", count: run.error_count)
130
135
  end
131
136
  end
132
137
  end
133
138
  end
134
139
 
140
+ # In place of the progress line: "0 of 50 targets" reads as a run that
141
+ # died at its first target. A rejected run never started, and says so.
142
+ def render_rejected
143
+ div(class: "text-xs text-[var(--pu-text-muted)]") do
144
+ plain t("plutonium.async.progress.rejected")
145
+ end
146
+ end
147
+
135
148
  # NEVER rendered without {#render_status} above it. After a refused
136
149
  # partial batch (:halt / :transactional), progress_done counts targets
137
150
  # DISPOSITIONED, not applied — so a run that performed nothing at all can
@@ -201,19 +214,20 @@ module Plutonium
201
214
  ((Time.current - started) * 1000).to_i % SWEEP_DURATION_MS
202
215
  end
203
216
 
217
+ # The framework's inline alert, so the problems read like every other
218
+ # message Plutonium shows rather than as a panel of their own.
204
219
  def render_failures
205
- div(class: "space-y-1") do
206
- div(class: "text-[10px] font-semibold uppercase tracking-wider text-[var(--pu-text-muted)]") do
207
- plain t("plutonium.async.progress.failures")
208
- end
209
- ul(class: "pu-run-progress-failures text-xs space-y-1") do
210
- run.errors_log.each do |entry|
211
- li(class: "text-[var(--pu-text-danger)]") { plain failure_text(entry) }
212
- end
220
+ div(class: "pu-alert pu-alert-#{failures_variant} pu-run-progress-failures", role: "alert") do
221
+ ul(class: "pu-alert-message") do
222
+ run.errors_log.each { |entry| li { plain failure_text(entry) } }
213
223
  end
214
224
  end
215
225
  end
216
226
 
227
+ # Danger only for a run that broke. Refused input (rejected) and partial
228
+ # application (completed_with_errors) are warnings, matching the badge.
229
+ def failures_variant = (run.outcome == "failed") ? "danger" : "warning"
230
+
217
231
  # A nil target_id is the RUN-LEVEL sentinel (see AsyncRun#record_target_failure!),
218
232
  # not a target whose id happens to be blank.
219
233
  def failure_text(entry)
@@ -4,7 +4,9 @@ module Plutonium
4
4
  module UI
5
5
  module Interaction
6
6
  module Async
7
- # Lists the runs currently working on THIS resource, above its collection.
7
+ # Lists the runs currently working on THIS resource, above its collection,
8
+ # followed by the viewer's own runs that finished within
9
+ # +recent_runs_window+ (see Page::Index#render_running_banner).
8
10
  #
9
11
  # == Why it exists
10
12
  #
@@ -16,8 +18,9 @@ module Plutonium
16
18
  #
17
19
  # +target_type+ is a real column rather than a key inside the +options+
18
20
  # JSON precisely so this predicate can be indexed — see the migration's own
19
- # comment. An opaque run has no +target_type+ at all and therefore appears
20
- # on no resource index: it belongs to no resource.
21
+ # comment. An opaque run dispatched from a resource still records that
22
+ # resource as its +target_type+, so it shows on that resource's index; one
23
+ # dispatched from nowhere has none and appears on no resource index.
21
24
  #
22
25
  # == Why it can render nothing
23
26
  #
@@ -51,13 +54,26 @@ module Plutonium
51
54
  # own page renders the pair.
52
55
  span(class: "font-medium text-[var(--pu-text)]") { plain run.to_label }
53
56
  plain " "
54
- span(class: "text-[var(--pu-text-muted)]") { plain(Plutonium::Translation.value_label(run.class, :state, run.state) || run.state.humanize) }
57
+ run.in_progress? ? render_state(run) : render_outcome(run)
55
58
  end
56
59
  a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary shrink-0") do
57
- plain t("plutonium.async.banner.view_progress")
60
+ plain t(run.in_progress? ? "plutonium.async.banner.view_progress" : "plutonium.async.banner.view_run")
58
61
  end
59
62
  end
60
63
  end
64
+
65
+ def render_state(run)
66
+ span(class: "text-[var(--pu-text-muted)]") { plain(Plutonium::Translation.value_label(run.class, :state, run.state) || run.state.humanize) }
67
+ end
68
+
69
+ # A finished run is listed for what it ended as, so outcome rather than
70
+ # state ("completed" hides a partial :continue run; "failed" hides a
71
+ # rejection), in the progress panel's colours.
72
+ def render_outcome(run)
73
+ span(class: "pu-badge pu-badge-#{RunProgress::BADGE_VARIANTS.fetch(run.outcome, "neutral")}") do
74
+ plain(Plutonium::Translation.value_label(run.class, :outcome, run.outcome) || run.outcome.humanize)
75
+ end
76
+ end
61
77
  end
62
78
  end
63
79
  end
@@ -244,15 +244,6 @@ module Plutonium
244
244
  "sha384-uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
245
245
  crossorigin: "anonymous"
246
246
  )
247
- # EasyMDE's toolbar uses Font Awesome 4 class names (renamed in 5+).
248
- link(
249
- rel: "stylesheet",
250
- href:
251
- "https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css",
252
- integrity:
253
- "sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN",
254
- crossorigin: "anonymous"
255
- )
256
247
  end
257
248
 
258
249
  def render_scripts
@@ -36,6 +36,7 @@ module Plutonium
36
36
  attr_reader :dashboard
37
37
 
38
38
  def render_content
39
+ render Plutonium::UI::Dashboard::Toolbar.new(dashboard:)
39
40
  render Plutonium::UI::Dashboard::Board.new(dashboard:)
40
41
  end
41
42
 
@@ -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
@@ -42,7 +42,7 @@ module Plutonium
42
42
  end
43
43
 
44
44
  def scope_label(name)
45
- Plutonium::Translation.label_for(:scope, current_query_object.resource_class, name) || name.to_s.humanize
45
+ current_query_object.scope_label(name)
46
46
  end
47
47
 
48
48
  def current_scope
@@ -32,7 +32,7 @@ module Plutonium
32
32
 
33
33
  def render_pill(key)
34
34
  active = current_query_object.selected_scope.to_s == key.to_s
35
- label = Plutonium::Translation.label_for(:scope, current_query_object.resource_class, key) || key.to_s.humanize
35
+ label = current_query_object.scope_label(key)
36
36
 
37
37
  a(
38
38
  id: "#{key}-scope",
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.67.0"
2
+ VERSION = "0.69.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.67.0",
3
+ "version": "0.69.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
@@ -60,7 +60,7 @@ Gem::Specification.new do |spec|
60
60
  spec.add_dependency "phlex-rails"
61
61
  spec.add_dependency "phlex-tabler_icons"
62
62
  spec.add_dependency "phlexi-field", ">= 0.2.0"
63
- spec.add_dependency "phlexi-form", ">= 0.14.3"
63
+ spec.add_dependency "phlexi-form", ">= 0.14.4"
64
64
  spec.add_dependency "phlexi-table", ">= 0.2.0"
65
65
  spec.add_dependency "phlexi-display", ">= 0.2.0"
66
66
  spec.add_dependency "phlexi-menu", ">= 0.4.1"
@@ -1222,6 +1222,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1222
1222
  margin-left: var(--pu-drag-marker-gap, 4px);
1223
1223
  }
1224
1224
 
1225
+ /* The failures on an async run's progress panel: an inline alert holding a
1226
+ list, so it sits flush in the card and the list keeps its bullets. */
1227
+ .pu-run-progress-failures {
1228
+ @apply mb-0 items-start;
1229
+ }
1230
+ .pu-run-progress-failures .pu-alert-message {
1231
+ @apply ms-0 ps-4 list-disc space-y-1;
1232
+ }
1233
+
1225
1234
  /* Indeterminate progress for an async run with no denominator (opaque work).
1226
1235
  A short segment sweeping the track: the motion is what says "alive", which a
1227
1236
  static bar at any width cannot, and a full-width bar would misread as done. */