plutonium 0.56.3 → 0.58.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.
@@ -21,7 +21,7 @@
21
21
  </div>
22
22
 
23
23
  <div class="hc-term-wrap">
24
- <pre class="pu-term hc-term"><span class="prompt">$</span> rails new my_app -m {{ activeUrl }}<span class="pu-term-cursor"></span></pre>
24
+ <pre class="pu-term hc-term"><span class="prompt">$</span> {{ activeCommand }}<span class="pu-term-cursor"></span></pre>
25
25
  <button class="hc-copy" :class="{ 'hc-copy--ok': copied }" @click="copy" :title="copied ? 'Copied' : 'Copy command'" :aria-label="copied ? 'Copied' : 'Copy command'">
26
26
  <component :is="copied ? IconCheck : IconCopy" :size="16" :stroke-width="2" />
27
27
  </button>
@@ -49,7 +49,7 @@ const options = [
49
49
  ]
50
50
  const selected = ref("plutonium")
51
51
  const activeUrl = computed(() => options.find(o => o.id === selected.value).url)
52
- const activeCommand = computed(() => `rails new my_app -m ${activeUrl.value}`)
52
+ const activeCommand = computed(() => `rails new my_app -a propshaft -j esbuild -c tailwind -m ${activeUrl.value}`)
53
53
  const copied = ref(false)
54
54
 
55
55
  async function copy() {
@@ -10,5 +10,5 @@ after_bundle do
10
10
  generate "pu:core:install"
11
11
 
12
12
  git add: "."
13
- git commit: %( -m 'install plutonium' )
13
+ git commit: %( -m 'chore: install plutonium' )
14
14
  end
@@ -2,51 +2,51 @@ after_bundle do
2
2
  # SQLite infrastructure (replaces Redis/Postgres for simple deployments)
3
3
  generate "pu:lite:setup"
4
4
  git add: "."
5
- git commit: %( -m 'setup sqlite') if `git status --porcelain`.present?
5
+ git commit: %( -m 'chore: setup sqlite') if `git status --porcelain`.present?
6
6
 
7
7
  generate "pu:lite:tune"
8
8
  git add: "."
9
- git commit: %( -m 'tune sqlite pragmas') if `git status --porcelain`.present?
9
+ git commit: %( -m 'chore: tune sqlite pragmas') if `git status --porcelain`.present?
10
10
 
11
11
  unless ENV["SKIP_SOLID_QUEUE"]
12
12
  generate "pu:lite:solid_queue"
13
13
  git add: "."
14
- git commit: %( -m 'add solid_queue') if `git status --porcelain`.present?
14
+ git commit: %( -m 'chore: add solid_queue') if `git status --porcelain`.present?
15
15
  end
16
16
 
17
17
  unless ENV["SKIP_SOLID_CACHE"]
18
18
  generate "pu:lite:solid_cache"
19
19
  git add: "."
20
- git commit: %( -m 'add solid_cache') if `git status --porcelain`.present?
20
+ git commit: %( -m 'chore: add solid_cache') if `git status --porcelain`.present?
21
21
  end
22
22
 
23
23
  unless ENV["SKIP_SOLID_CABLE"]
24
24
  generate "pu:lite:solid_cable"
25
25
  git add: "."
26
- git commit: %( -m 'add solid_cable') if `git status --porcelain`.present?
26
+ git commit: %( -m 'chore: add solid_cable') if `git status --porcelain`.present?
27
27
  end
28
28
 
29
29
  unless ENV["SKIP_SOLID_ERRORS"]
30
30
  generate "pu:lite:solid_errors"
31
31
  git add: "."
32
- git commit: %( -m 'add solid_errors') if `git status --porcelain`.present?
32
+ git commit: %( -m 'chore: add solid_errors') if `git status --porcelain`.present?
33
33
  end
34
34
 
35
35
  unless ENV["SKIP_LITESTREAM"]
36
36
  generate "pu:lite:litestream"
37
37
  git add: "."
38
- git commit: %( -m 'add litestream') if `git status --porcelain`.present?
38
+ git commit: %( -m 'chore: add litestream') if `git status --porcelain`.present?
39
39
  end
40
40
 
41
41
  unless ENV["SKIP_RAILS_PULSE"]
42
42
  generate "pu:lite:rails_pulse"
43
43
  git add: "."
44
- git commit: %( -m 'add rails_pulse') if `git status --porcelain`.present?
44
+ git commit: %( -m 'chore: add rails_pulse') if `git status --porcelain`.present?
45
45
  end
46
46
 
47
47
  unless ENV["SKIP_SQLITE_MAINTENANCE"]
48
48
  generate "pu:lite:maintenance"
49
49
  git add: "."
50
- git commit: %( -m 'add sqlite maintenance job') if `git status --porcelain`.present?
50
+ git commit: %( -m 'chore: add sqlite maintenance job') if `git status --porcelain`.present?
51
51
  end
52
52
  end
@@ -1,6 +1,6 @@
1
1
  after_bundle do
2
2
  # We just installed Rails, let's create a commit
3
- git(add: ".") && git(commit: %( -m 'initial commit' ))
3
+ git(add: ".") && git(commit: %( -m 'chore: initial commit' ))
4
4
 
5
5
  # Run the base install
6
6
  template_location = if ENV["LOCAL"]
@@ -12,20 +12,23 @@ after_bundle do
12
12
 
13
13
  # Add development tools
14
14
  generate "pu:gem:dotenv"
15
- git(add: ".") && git(commit: %( -m 'add dotenv' ))
15
+ git(add: ".") && git(commit: %( -m 'chore: add dotenv' ))
16
16
 
17
17
  generate "pu:gem:annotated"
18
- git(add: ".") && git(commit: %( -m 'add annotate' ))
18
+ git(add: ".") && git(commit: %( -m 'chore: add annotate' ))
19
19
 
20
20
  generate "pu:gem:standard"
21
- git(add: ".") && git(commit: %( -m 'add standardrb' ))
21
+ git(add: ".") && git(commit: %( -m 'chore: add standardrb' ))
22
22
 
23
23
  generate "pu:gem:letter_opener"
24
- git(add: ".") && git(commit: %( -m 'add letter_opener' ))
24
+ git(add: ".") && git(commit: %( -m 'chore: add letter_opener' ))
25
25
 
26
26
  generate "pu:gem:actual_db_schema"
27
- git(add: ".") && git(commit: %( -m 'add actual_db_schema' ))
27
+ git(add: ".") && git(commit: %( -m 'chore: add actual_db_schema' ))
28
28
 
29
29
  generate "pu:core:assets"
30
- git(add: ".") && git(commit: %( -m 'integrate assets' ))
30
+ git(add: ".") && git(commit: %( -m 'chore: integrate assets' ))
31
+
32
+ generate "pu:skills:sync"
33
+ git(add: ".") && git(commit: %( -m 'chore: sync plutonium skills' ))
31
34
  end
@@ -214,6 +214,26 @@ When a default is set:
214
214
  - The default scope button is highlighted (not "All").
215
215
  - Clicking "All" shows the unscoped collection.
216
216
 
217
+ ### Conditional visibility — `condition:`
218
+
219
+ Like `condition:` on [actions](./actions), a scope can be **defined but only render its button when a runtime proc is truthy**. The scope (and its URL) stays live either way — `condition:` only toggles the button.
220
+
221
+ ```ruby
222
+ scope :admin_only, condition: -> { current_user.admin? }
223
+ scope :beta_feature, condition: -> { params[:beta] == "1" }
224
+
225
+ # Expose a scope's URL (API/programmatic) without surfacing a button
226
+ scope :internal, condition: -> { false }
227
+ ```
228
+
229
+ The proc is evaluated against the view context so `current_user`, `params`, `request`, and `allowed_to?` are all available directly. There is no `object`/`record` — scopes have no single-record context.
230
+
231
+ ::: danger `condition:` is NOT authorization
232
+ A hidden scope button still has a **live URL** anyone can navigate to. `condition:` decides whether the *button renders*, not whether the *records are accessible*.
233
+
234
+ Use `condition:` for UI relevance ("show this tab to admins only"). Use the policy's `relation_scope` to restrict which records a user can see at all.
235
+ :::
236
+
217
237
  ## Sorting
218
238
 
219
239
  ```ruby
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.56.0)
4
+ plutonium (0.57.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.56.0)
4
+ plutonium (0.57.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.56.0)
4
+ plutonium (0.57.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -12,6 +12,7 @@ module Pu
12
12
  desc "Setup plutonium assets"
13
13
 
14
14
  def start
15
+ verify_prerequisites
15
16
  install_dependencies
16
17
  copy_tailwind_config
17
18
  configure_application
@@ -24,6 +25,22 @@ module Pu
24
25
 
25
26
  private
26
27
 
28
+ # The asset pipeline assumes the app was generated with esbuild + Tailwind.
29
+ # Without those, `application.tailwind.css` doesn't exist and the generator
30
+ # later crashes with a cryptic inject_into_file error. Fail early with a fix.
31
+ def verify_prerequisites
32
+ return if File.exist?("app/assets/stylesheets/application.tailwind.css")
33
+
34
+ error <<~MSG
35
+ Plutonium assets require a Rails app generated with esbuild and Tailwind.
36
+ Expected app/assets/stylesheets/application.tailwind.css, but it is missing.
37
+
38
+ Re-create the app with the required flags:
39
+ rails new myapp -a propshaft -j esbuild -c tailwind \\
40
+ -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
41
+ MSG
42
+ end
43
+
27
44
  def copy_tailwind_config
28
45
  copy_file "tailwind.config.js", force: true
29
46
  copy_file "postcss.config.js", force: true
@@ -1,17 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Rails.application.config.after_initialize do
4
- default_url = ENV["RAILS_DEFAULT_URL"]
5
- if default_url && Rails.application.config.action_mailer.default_url_options.blank?
6
- uri = URI.parse(default_url)
7
- Rails.application.config.action_mailer.default_url_options = {
8
- host: uri.host,
9
- port: uri.port,
10
- protocol: uri.scheme
11
- }
12
- end
3
+ if (default_url = ENV["RAILS_DEFAULT_URL"])
4
+ uri = URI.parse(default_url)
5
+ default_port = (uri.scheme == "https") ? 443 : 80
6
+ url_options = {host: uri.host, protocol: uri.scheme}
7
+ .tap { |opts| opts[:port] = uri.port if uri.port != default_port }
13
8
 
14
- if Rails.application.routes.default_url_options.blank?
15
- Rails.application.routes.default_url_options = Rails.application.config.action_mailer.default_url_options
16
- end
9
+ ActionMailer::Base.default_url_options = url_options if ActionMailer::Base.default_url_options.blank?
10
+ Rails.application.routes.default_url_options = url_options if Rails.application.routes.default_url_options.blank?
17
11
  end
@@ -115,7 +115,10 @@ module Plutonium
115
115
  def remember_scoped_entity
116
116
  return unless scoped_to_entity?
117
117
 
118
- session[scoped_entity_session_key] = current_scoped_entity.to_global_id.to_s
118
+ entity = current_scoped_entity
119
+ return unless entity
120
+
121
+ session[scoped_entity_session_key] = entity.to_global_id.to_s
119
122
  end
120
123
 
121
124
  # Retrieves the remembered scoped entity from the session.
@@ -70,16 +70,14 @@ module Plutonium
70
70
  #
71
71
  # @return [Plutonium::Interaction::Outcome] The result of the interaction.
72
72
  def call
73
- if valid?
74
- outcome = execute
75
- unless outcome.is_a?(Plutonium::Interaction::Outcome)
76
- raise "#{self.class}#execute must return an instance of Plutonium::Interaction::Outcome.\n" \
77
- "#{outcome.inspect} received instead"
78
- end
79
- outcome
80
- else
81
- failure.with_message("An error occurred")
73
+ return failure unless valid?
74
+
75
+ outcome = execute
76
+ unless outcome.is_a?(Plutonium::Interaction::Outcome)
77
+ raise "#{self.class}#execute must return an instance of Plutonium::Interaction::Outcome.\n" \
78
+ "#{outcome.inspect} received instead"
82
79
  end
80
+ outcome
83
81
  end
84
82
 
85
83
  private
@@ -41,7 +41,7 @@ module Plutonium
41
41
 
42
42
  # Callbacks
43
43
  before_validation :set_token_defaults, on: :create
44
- after_create :send_invitation_email
44
+ after_commit :send_invitation_email, on: :create
45
45
 
46
46
  # Core validations
47
47
  validates :email, presence: true
@@ -146,6 +146,21 @@ module Plutonium
146
146
  # Pass form_action: false to prevent form from trying to generate URL (cloned record has id: nil)
147
147
  extraction_record = resource_record?&.dup || resource_class.new
148
148
  @submitted_resource_params ||= begin
149
+ # Pre-populate from submitted params so condition: procs evaluate against submitted
150
+ # values during extraction. Without this, a select whose choices: depend on a sibling
151
+ # attribute would see nil for that sibling (fresh/cloned record) and resolve to empty
152
+ # choices, causing AcceptsChoices to nullify a valid submitted value.
153
+ # attribute_names covers DB columns and `attribute :` declarations.
154
+ # The union with respond_to? also covers attr_accessor virtual attributes.
155
+ submitted = params[resource_param_key]&.to_unsafe_h || {}
156
+ base_keys = extraction_record.attribute_names.map(&:to_s)
157
+ # Also include attr_accessor virtual attributes not in attribute_names.
158
+ # Exclude AR association writers — they expect object instances, not param strings.
159
+ extra_keys = (submitted.keys.map(&:to_s) - base_keys).select { |k|
160
+ extraction_record.respond_to?("#{k}=") &&
161
+ extraction_record.class.reflect_on_association(k.to_sym).nil?
162
+ }
163
+ extraction_record.assign_attributes(submitted.slice(*(base_keys | extra_keys)))
149
164
  extracted = build_form(extraction_record, form_action: false)
150
165
  .extract_input(params, view_context:)[resource_param_key.to_sym].compact
151
166
  clean_structured_inputs(current_definition, extracted)
@@ -262,6 +262,17 @@ module Plutonium
262
262
  elsif action.bulk_action?
263
263
  instance.resources = interactive_bulk
264
264
  end
265
+ # Pre-populate sibling attributes from submitted params before rendering
266
+ # the form for extraction. When a select input's `choices:` depends on a
267
+ # sibling attribute and `condition:` guards it, the condition evaluates to
268
+ # false on a fresh instance (sibling is nil), making AcceptsChoices
269
+ # validate against an empty choices list and nullify a valid submitted
270
+ # value. Pre-populating lets conditions evaluate against submitted values,
271
+ # so the real select (with correct choices) is used for extraction.
272
+ submitted = params[:interaction]&.to_unsafe_h || {}
273
+ base_keys = instance.attribute_names.map(&:to_s) - %w[resource resources]
274
+ extra_keys = submitted.keys.map(&:to_s).select { |k| instance.respond_to?("#{k}=") } - %w[resource resources]
275
+ instance.assign_attributes(submitted.slice(*(base_keys | extra_keys)))
265
276
  extracted = interaction
266
277
  .build_form(instance)
267
278
  .extract_input(params, view_context:)[:interaction]
@@ -31,9 +31,11 @@ module Plutonium
31
31
  #
32
32
  # @param name [Symbol] The name of the scope.
33
33
  # @param body [Proc, nil] The body of the scope.
34
- def define_scope(name, body = nil, **)
34
+ # @param condition [Proc, nil] Display-only visibility gate (same semantics as condition: on actions).
35
+ def define_scope(name, body = nil, condition: nil, **)
35
36
  body ||= name
36
37
  scope_definitions[name] = build_query(body)
38
+ scope_conditions[name] = condition if condition
37
39
  end
38
40
 
39
41
  # Defines a sort with the given name and body.
@@ -118,6 +120,16 @@ module Plutonium
118
120
 
119
121
  def scope_definitions = @scope_definitions ||= {}.with_indifferent_access
120
122
 
123
+ def scope_conditions = @scope_conditions ||= {}.with_indifferent_access
124
+
125
+ # Display-only visibility gate for a scope, mirroring condition: on actions.
126
+ # Returns true when no condition is set.
127
+ def scope_visible?(name, view_context)
128
+ condition = scope_conditions[name]
129
+ return true if condition.nil?
130
+ Plutonium::Action::ConditionContext.new(view_context, nil).instance_exec(&condition)
131
+ end
132
+
121
133
  # Returns true if user explicitly selected "All" scope (no filtering)
122
134
  def all_scope_selected? = @all_scope_selected
123
135
 
@@ -33,7 +33,7 @@ module Plutonium
33
33
  # showModal() and is dropped when the dialog leaves the top layer
34
34
  # on close(), so it still covers the panel's slide-out. Mirrors
35
35
  # the .pu-dialog::backdrop rule in components.css.
36
- "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
36
+ "fixed top-0 right-0 bottom-0 left-auto m-0 h-dvh max-w-full max-h-dvh " \
37
37
  "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
38
38
  "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
39
39
  "rounded-none p-0 " \
@@ -14,6 +14,7 @@ module Plutonium
14
14
  div(class: "flex flex-wrap items-center gap-2") do
15
15
  render_all_scope_button
16
16
  current_query_object.scope_definitions.each_key do |name|
17
+ next unless current_query_object.scope_visible?(name, view_context)
17
18
  render_scope_button(name)
18
19
  end
19
20
  end
@@ -60,7 +61,7 @@ module Plutonium
60
61
  end
61
62
 
62
63
  def render?
63
- current_query_object.scope_definitions.present?
64
+ current_query_object.scope_definitions.any? { |name, _| current_query_object.scope_visible?(name, view_context) }
64
65
  end
65
66
  end
66
67
  end
@@ -59,7 +59,7 @@ module Plutonium
59
59
  end
60
60
 
61
61
  def scopes
62
- @scopes ||= current_query_object.scope_definitions
62
+ @scopes ||= current_query_object.scope_definitions.select { |name, _| current_query_object.scope_visible?(name, view_context) }
63
63
  end
64
64
  end
65
65
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.56.3"
2
+ VERSION = "0.58.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.56.3",
3
+ "version": "0.58.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",
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.56.3
4
+ version: 0.58.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-07 00:00:00.000000000 Z
10
+ date: 2026-06-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk