plutonium 0.60.1 → 0.60.3

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.
@@ -511,7 +511,12 @@ section :advanced, :seo_title, :notes,
511
511
  label: -> { object.new_record? ? "Set up" : "Advanced" }
512
512
  ```
513
513
 
514
- Empty sections (all fields filtered by the policy, or none assigned) are **not** hidden automatically. Use `condition:` to hide a section conditionally.
514
+ A section that resolves to **zero fields** — every declared field filtered out by the permitted set, or no field assigned — renders nothing at all (no heading, no grid). This keeps forms clean when fewer attributes are permitted than declared (notably `+ New`, where the create policy often permits a subset). The check is purely "are there fields to render"; it does **not** evaluate per-field `condition:` procs (those run later, at field render). So if you want a whole section to appear only under some state, gate it with the section's own `condition:` rather than relying on every field inside it being hidden:
515
+
516
+ ```ruby
517
+ section :shipping, :address, :city, :postcode,
518
+ condition: -> { object.requires_shipping? } # hide the section as a unit
519
+ ```
515
520
 
516
521
  ### `ungrouped(**opts)`
517
522
 
@@ -564,7 +569,7 @@ end
564
569
 
565
570
  ### Fields not in the permitted set are skipped
566
571
 
567
- A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there — a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting — is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out.
572
+ A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there — a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting — is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out. And when every field a section lists is dropped this way, the section's chrome is dropped with it (see the zero-fields note above) — so the same layout can serve a richly-permitted `edit` and a minimal `new` without leaving empty headings behind.
568
573
 
569
574
  ### On interactions
570
575
 
@@ -18,15 +18,44 @@ If you're starting fresh, use `:modern`. `:classic` exists so apps upgrading fro
18
18
 
19
19
  ## Shell variants & the icon rail
20
20
 
21
- `config.shell` selects the chrome for the whole app:
21
+ The shell variant selects the page chrome:
22
22
 
23
23
  - `:modern` (default) — Topbar plus the desktop icon rail.
24
- - `:plain` — Topbar but **no** icon rail. The Topbar is kept; only the rail is removed, so the whole app is rail-less.
24
+ - `:plain` — Topbar but **no** icon rail. The Topbar is kept; only the rail is removed, so the surface is rail-less.
25
25
  - `:classic` — legacy Header/Sidebar (upgrade paths only).
26
26
 
27
- ### Per-controller / per-portal override
27
+ ### Resolving the shell (global → engine → controller)
28
28
 
29
- Any Plutonium resource controller exposes a class-level `rail` DSL that overrides the shell default. It's a `class_attribute`, so it's inherited: a portal opts its entire surface in or out by calling `rail false` (or `rail true`) once in its controller concern.
29
+ The shell resolves across three layers, each overriding the one above it:
30
+
31
+ ```ruby
32
+ # 1. Global default (config/initializers/plutonium.rb)
33
+ Plutonium.configure { |config| config.shell = :modern }
34
+
35
+ # 2. Per-engine — set it on a portal engine (lib/engine.rb)
36
+ module CustomerPortal
37
+ class Engine < Rails::Engine
38
+ include Plutonium::Portal::Engine
39
+
40
+ config.after_initialize do
41
+ shell :plain # this whole portal is rail-less
42
+ end
43
+ end
44
+ end
45
+
46
+ # 3. Per-controller — overrides the engine/global default for one controller (and subclasses)
47
+ class DashboardController < ResourceController
48
+ shell :modern # opt this controller back into the rail
49
+ end
50
+ ```
51
+
52
+ `shell` takes a plain symbol, so it's safe in the class body too — but the generated engine already has a `config.after_initialize` block (where `scope_to_entity` lives), so keeping it there is the consistent home.
53
+
54
+ An unset engine/controller value (`nil`) falls through to the next layer. Read the resolved value with the `shell` helper (`controller.shell`).
55
+
56
+ ### `rail` — a controller-level rail toggle
57
+
58
+ Alongside `shell`, any resource controller exposes a class-level `rail` DSL that flips just the icon rail without changing the shell. It's a `class_attribute`, so it's inherited — a portal opts its entire surface in or out by calling `rail false` (or `rail true`) once in its controller concern:
30
59
 
31
60
  ```ruby
32
61
  module CustomerPortal
@@ -39,7 +68,7 @@ module CustomerPortal
39
68
  end
40
69
  ```
41
70
 
42
- `rail nil` (the default) inherits the shell default — the rail shows when `config.shell == :modern`. Read the resolved value with the `rail?` predicate.
71
+ `rail nil` (the default) inherits the resolved shell — the rail shows when the resolved `shell == :modern`. Read the resolved value with the `rail?` predicate.
43
72
 
44
73
  ### Stable CSS hooks
45
74
 
@@ -47,7 +47,8 @@ module Plutonium
47
47
  helper_method :registered_resources
48
48
 
49
49
  class_attribute :_rail_enabled, instance_writer: false, default: nil
50
- helper_method :rail?
50
+ class_attribute :_shell, instance_writer: false, default: nil
51
+ helper_method :rail?, :shell
51
52
  end
52
53
 
53
54
  class_methods do
@@ -56,6 +57,12 @@ module Plutonium
56
57
  def rail(enabled)
57
58
  self._rail_enabled = enabled
58
59
  end
60
+
61
+ # Set the shell variant for this controller and its subclasses,
62
+ # overriding the engine/global default. nil (default) inherits.
63
+ def shell(value)
64
+ self._shell = value
65
+ end
59
66
  end
60
67
 
61
68
  # Whether the modern icon rail is active for this request. Resolves the
@@ -63,7 +70,14 @@ module Plutonium
63
70
  # Public: the resource layout calls `controller.rail?`.
64
71
  def rail?
65
72
  return _rail_enabled unless _rail_enabled.nil?
66
- Plutonium.configuration.shell == :modern
73
+ shell == :modern
74
+ end
75
+
76
+ def shell
77
+ return _shell unless _shell.nil?
78
+
79
+ engine = current_engine
80
+ (engine == Rails.application.class) ? Plutonium.configuration.shell : engine.shell
67
81
  end
68
82
 
69
83
  private
@@ -10,6 +10,17 @@ module Plutonium
10
10
  included do
11
11
  isolate_namespace to_s.deconstantize.constantize
12
12
  end
13
+
14
+ class_methods do
15
+ # Shell variant for this portal's controllers (:modern / :plain /
16
+ # :classic). When unset it cascades live to the global
17
+ # Plutonium.configuration.shell; a controller's own `shell` overrides
18
+ # this in turn. Set with `shell :plain`, read with `shell`.
19
+ def shell(value = nil)
20
+ @shell = value unless value.nil?
21
+ @shell.nil? ? Plutonium.configuration.shell : @shell
22
+ end
23
+ end
13
24
  end
14
25
  end
15
26
  end
@@ -13,16 +13,26 @@ module Plutonium
13
13
  @grid_class = grid_class
14
14
  end
15
15
 
16
- SECTION_CLASS = "space-y-4 border-t border-[var(--pu-border-muted)] pt-6 first:border-t-0 first:pt-0"
17
- HEADING_CLASS = "text-base font-semibold text-[var(--pu-text)]"
18
- SUMMARY_CLASS = "#{HEADING_CLASS} cursor-pointer select-none"
19
- DESCRIPTION_CLASS = "text-sm text-[var(--pu-text-muted)]"
16
+ SECTION_CLASS = "space-y-5 border-t border-[var(--pu-border)] pt-7 first:border-t-0 first:pt-0"
17
+ # A short primary accent rule to the left of the heading — anchors the
18
+ # section and adds a touch of brand. Shared by the grouped <div> header
19
+ # and the collapsible <summary> so both read the same.
20
+ ACCENT_CLASS = "border-l-[3px] border-primary-500 pl-3.5"
21
+ HEADING_CLASS = "text-base font-semibold tracking-tight text-[var(--pu-text)]"
22
+ SUMMARY_CLASS = "#{HEADING_CLASS} #{ACCENT_CLASS} cursor-pointer select-none"
23
+ # font-normal resets the semibold inherited from a <summary> parent.
24
+ DESCRIPTION_CLASS = "mt-1 text-sm font-normal text-[var(--pu-text-muted)]"
20
25
 
21
26
  def view_template(&fields_block)
22
27
  if @section.collapsible?
23
28
  details(open: !@section.collapsed?, class: SECTION_CLASS) do
24
- summary(class: SUMMARY_CLASS) { heading_text }
25
- describe
29
+ # <summary> must be the first child of <details> and can't be
30
+ # wrapped, so the title text and its description both live inside
31
+ # it — keeping the description hugging the title under one accent.
32
+ summary(class: SUMMARY_CLASS) do
33
+ plain heading_text
34
+ describe
35
+ end
26
36
  grid(&fields_block)
27
37
  end
28
38
  else
@@ -35,10 +45,15 @@ module Plutonium
35
45
 
36
46
  private
37
47
 
48
+ # Title + description grouped under one accented header so the
49
+ # description hugs the heading (mt-1) instead of inheriting the
50
+ # section's larger vertical rhythm.
38
51
  def header_block
39
52
  return if @section.ungrouped? && @section.options[:label].nil?
40
- h3(class: HEADING_CLASS) { heading_text }
41
- describe
53
+ div(class: ACCENT_CLASS) do
54
+ h3(class: HEADING_CLASS) { heading_text }
55
+ describe
56
+ end
42
57
  end
43
58
 
44
59
  def heading_text = @section.label
@@ -122,6 +122,16 @@ module Plutonium
122
122
  condition = section.condition
123
123
  next if condition && !instance_exec(&condition)
124
124
 
125
+ # Drop sections left with no fields — e.g. every declared field was
126
+ # filtered out by the permitted set (policy, per-action, scoping,
127
+ # nesting). Rendering the chrome (heading + empty grid) for these
128
+ # litters the form with empty headings (notably on `+ New`, where
129
+ # fewer attributes are permitted than declared). Field-level
130
+ # `condition:` is evaluated later, at render — a section whose fields
131
+ # are all condition-hidden is the author's call to gate via the
132
+ # section's own `condition:`.
133
+ next if resolved.fields.empty?
134
+
125
135
  # `columns` stays a validated literal; everything else may be a proc.
126
136
  options = section.options.to_h do |key, value|
127
137
  [key, (key != :condition && value.is_a?(Proc)) ? instance_exec(&value) : value]
@@ -52,7 +52,7 @@ module Plutonium
52
52
  # signal) for non-classic shells, since Turbo Drive does not update
53
53
  # <html> attributes across cross-URL visits.
54
54
  def render_pre_paint_scripts
55
- manage_no_rail = Plutonium.configuration.shell != :classic
55
+ manage_no_rail = pre_paint_shell != :classic
56
56
  script do
57
57
  raw(safe(<<~JS))
58
58
  (function () {
@@ -80,6 +80,11 @@ module Plutonium
80
80
  end
81
81
  end
82
82
 
83
+ # Shell variant used by pre-paint scripts. Base uses the global config
84
+ # (safe for non-resource layouts whose controllers lack #shell);
85
+ # ResourceLayout overrides this to the resolved shell.
86
+ def pre_paint_shell = Plutonium.configuration.shell
87
+
83
88
  def render_body(&)
84
89
  body(**body_attributes) {
85
90
  render_before_main
@@ -8,6 +8,12 @@ module Plutonium
8
8
  # the controller's resolution (shell default + per-controller `rail`).
9
9
  def rail? = controller.rail?
10
10
 
11
+ def shell = controller.shell
12
+
13
+ # Override Base's seam so all pre-paint shell logic uses the resolved
14
+ # (controller/engine/global) shell rather than only the global config.
15
+ def pre_paint_shell = shell
16
+
11
17
  # Sets pu-rail-pinned immediately on initial page load so the rail
12
18
  # renders in its pinned state from the first frame. Turbo navigations
13
19
  # are handled by the turbo:before-render listener in Base. Skipped
@@ -34,13 +40,13 @@ module Plutonium
34
40
  # Scoped to the modern family — :classic keeps its own offsets.
35
41
  def html_attributes
36
42
  attrs = super
37
- return attrs if Plutonium.configuration.shell == :classic
43
+ return attrs if shell == :classic
38
44
 
39
45
  rail? ? attrs : mix(attrs, {class: "pu-no-rail"})
40
46
  end
41
47
 
42
48
  def main_attributes
43
- classes = if Plutonium.configuration.shell == :classic
49
+ classes = if shell == :classic
44
50
  "pt-20 lg:ml-64"
45
51
  else
46
52
  rail? ? "pt-16 pb-6 px-6 lg:pl-20" : "pt-16 pb-6 px-6"
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.60.1"
2
+ VERSION = "0.60.3"
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.60.1",
3
+ "version": "0.60.3",
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.60.1
4
+ version: 0.60.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich