plutonium 0.56.2 → 0.57.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
@@ -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.
@@ -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
 
@@ -44,11 +44,12 @@ module Plutonium
44
44
  # preferences read from localStorage:
45
45
  # - Color mode: applies `dark` class on <html> so dark theme renders
46
46
  # from the first frame instead of flashing light.
47
- # - Rail-pin: applies `pu-rail-pinned` on <body> (when present) and
48
- # on every incoming body via turbo:before-render, so a
49
- # Turbo.visit (e.g. the redirect after a form submit) doesn't
50
- # flash the rail into its collapsed state before the
51
- # icon-rail Stimulus controller can restore it.
47
+ # - Rail-pin: the rail is pinned by default, so this applies
48
+ # `pu-rail-pinned` on <body> (when present) and on every incoming
49
+ # body via turbo:before-render unless the user explicitly collapsed
50
+ # it (localStorage "false"), so a Turbo.visit (e.g. the redirect
51
+ # after a form submit) doesn't flash the rail into its collapsed
52
+ # state before the icon-rail Stimulus controller can restore it.
52
53
  def render_pre_paint_scripts
53
54
  script do
54
55
  raw(safe(<<~JS))
@@ -62,10 +63,10 @@ module Plutonium
62
63
  } catch (e) {}
63
64
 
64
65
  try {
65
- if (localStorage.getItem("pu_rail_pinned") !== "true") return;
66
+ if (localStorage.getItem("pu_rail_pinned") === "false") return;
66
67
  if (document.body) document.body.classList.add("pu-rail-pinned");
67
68
  document.addEventListener("turbo:before-render", function (event) {
68
- if (localStorage.getItem("pu_rail_pinned") === "true") {
69
+ if (localStorage.getItem("pu_rail_pinned") !== "false") {
69
70
  event.detail.newBody.classList.add("pu-rail-pinned");
70
71
  }
71
72
  });
@@ -39,7 +39,7 @@ module Plutonium
39
39
  id: "sidebar-navigation",
40
40
  data: {controller: "sidebar icon-rail"},
41
41
  aria: {label: "Sidebar Navigation"},
42
- class: "fixed top-0 left-0 z-40 h-screen " \
42
+ class: "fixed top-0 left-0 z-40 h-dvh " \
43
43
  "bg-[var(--pu-surface)] border-r border-[var(--pu-border)] " \
44
44
  "flex flex-col transition-[width] duration-200 overflow-x-hidden " \
45
45
  "-translate-x-full lg:translate-x-0"
@@ -54,7 +54,11 @@ module Plutonium
54
54
 
55
55
  def render_brand_section
56
56
  div(class: "h-12 flex items-center justify-center border-b border-[var(--pu-border)] shrink-0") do
57
- render brand_slot if brand_slot?
57
+ next unless brand_slot?
58
+
59
+ a(href: root_path, aria: {label: "Home"}, class: "flex items-center justify-center") do
60
+ render brand_slot
61
+ end
58
62
  end
59
63
  end
60
64
 
@@ -18,7 +18,7 @@ module Plutonium
18
18
  data: {controller: "sidebar"},
19
19
  id: "sidebar-navigation",
20
20
  aria: {label: "Sidebar Navigation"},
21
- class: "fixed top-0 left-0 z-40 w-64 h-screen pt-14 transition-transform -translate-x-full lg:translate-x-0"
21
+ class: "fixed top-0 left-0 z-40 w-64 h-dvh pt-14 transition-transform -translate-x-full lg:translate-x-0"
22
22
  ) do
23
23
  div(
24
24
  id: "sidebar-navigation-content",
@@ -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
@@ -10,7 +10,8 @@ module Plutonium
10
10
 
11
11
  nav(role: "tablist",
12
12
  aria: {label: "Scope"},
13
- class: "flex items-center gap-1 px-4 py-2 border-b border-[var(--pu-border)]") do
13
+ class: "flex flex-nowrap items-center gap-1 px-4 py-2 border-b border-[var(--pu-border)] " \
14
+ "overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden") do
14
15
  render_all_pill
15
16
  scopes.each_key { |key| render_pill(key) }
16
17
  end
@@ -43,7 +44,7 @@ module Plutonium
43
44
  end
44
45
 
45
46
  def pill_classes(active)
46
- base = "px-3 py-1 rounded-md text-sm transition-colors"
47
+ base = "shrink-0 px-3 py-1 rounded-md text-sm transition-colors"
47
48
  state = if active
48
49
  "bg-primary-100 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300"
49
50
  else
@@ -58,7 +59,7 @@ module Plutonium
58
59
  end
59
60
 
60
61
  def scopes
61
- @scopes ||= current_query_object.scope_definitions
62
+ @scopes ||= current_query_object.scope_definitions.select { |name, _| current_query_object.scope_visible?(name, view_context) }
62
63
  end
63
64
  end
64
65
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.56.2"
2
+ VERSION = "0.57.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.2",
3
+ "version": "0.57.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",
@@ -780,8 +780,11 @@ body.pu-rail-pinned .icon-rail-pin-expand {
780
780
  box-shadow: var(--pu-shadow-lg);
781
781
  padding: 6px;
782
782
  animation: pu-rail-flyout-in 120ms ease-out;
783
- /* Cap to the viewport so long menus scroll instead of overflowing. */
783
+ /* Cap to the viewport so long menus scroll instead of overflowing.
784
+ dvh tracks the visible viewport so the cap holds on mobile (where
785
+ 100vh sits behind the browser chrome); 100vh is the fallback. */
784
786
  max-height: calc(100vh - 16px);
787
+ max-height: calc(100dvh - 16px);
785
788
  overflow-y: auto;
786
789
  overscroll-behavior: contain;
787
790
  }
@@ -9,10 +9,9 @@ export default class extends Controller {
9
9
  }
10
10
 
11
11
  connect() {
12
- const pinned = localStorage.getItem(this.storageKeyValue) === "true"
13
- if (pinned) {
14
- document.body.classList.add("pu-rail-pinned")
15
- }
12
+ // Pinned is the default; only an explicit "false" collapses the rail.
13
+ const pinned = localStorage.getItem(this.storageKeyValue) !== "false"
14
+ document.body.classList.toggle("pu-rail-pinned", pinned)
16
15
  }
17
16
 
18
17
  togglePin() {
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.2
4
+ version: 0.57.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-05 00:00:00.000000000 Z
10
+ date: 2026-06-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk