plutonium 0.60.0 → 0.60.2

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.
@@ -0,0 +1,51 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-06-14-railless-portal.md",
3
+ "tasks": [
4
+ {
5
+ "id": 1,
6
+ "subject": "Task 1: Controller rail DSL + rail? resolution",
7
+ "status": "completed",
8
+ "description": "**Goal:** Add inherited `rail` class DSL and `rail?` predicate to `Plutonium::Core::Controller`, defaulting from `config.shell`.\n\n**Files:** Modify lib/plutonium/core/controller.rb; create test/plutonium/core/controller_rail_test.rb\n\n```json:metadata\n{\"files\": [\"lib/plutonium/core/controller.rb\", \"test/plutonium/core/controller_rail_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/core/controller_rail_test.rb\", \"acceptanceCriteria\": [\"rail? resolves shell default\", \"rail DSL overrides + inherits\", \"rail? public helper\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 2,
12
+ "subject": "Task 2: Gate ResourceLayout rendering on rail?",
13
+ "status": "completed",
14
+ "blockedBy": [1],
15
+ "description": "**Goal:** ResourceLayout delegates rail? to controller; skips sidebar partial, initial pin script, and main rail offset when rail-less; emits pu-no-rail on <html>.\n\n**Files:** Modify lib/plutonium/ui/layout/resource_layout.rb; create test/plutonium/ui/layout/resource_layout_rail_test.rb\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/layout/resource_layout.rb\", \"test/plutonium/ui/layout/resource_layout_rail_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/resource_layout_rail_test.rb\", \"acceptanceCriteria\": [\"rail? delegates\", \"sidebar/pin gated\", \"main_attributes branches\", \"pu-no-rail emitted\"], \"requiresUserVerification\": false}\n```"
16
+ },
17
+ {
18
+ "id": 3,
19
+ "subject": "Task 3: Stable hooks + pu-no-rail CSS + asset build",
20
+ "status": "completed",
21
+ "description": "**Goal:** Add pu-topbar/pu-sticky-footer classes; CSS cancels lg:left-14 under html.pu-no-rail; rebuild assets via yarn build.\n\n**Files:** Modify topbar.rb, sticky_footer.rb, src/css/components.css; regenerate app/assets/plutonium.css + .min.js; modify topbar_test.rb, sticky_footer_test.rb\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/layout/topbar.rb\", \"lib/plutonium/ui/form/components/sticky_footer.rb\", \"src/css/components.css\", \"app/assets/plutonium.css\", \"app/assets/plutonium.min.js\", \"test/plutonium/ui/layout/topbar_test.rb\", \"test/plutonium/ui/form/components/sticky_footer_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb test/plutonium/ui/form/components/sticky_footer_test.rb\", \"acceptanceCriteria\": [\"hook classes added\", \"CSS cancel rule\", \"assets rebuilt\"], \"requiresUserVerification\": false}\n```"
22
+ },
23
+ {
24
+ "id": 4,
25
+ "subject": "Task 4: Integration test for plain vs modern shell",
26
+ "status": "completed",
27
+ "blockedBy": [1, 2, 3],
28
+ "description": "**Goal:** End-to-end: plain shell omits icon-rail + pin script, has pu-no-rail; modern unchanged.\n\n**Files:** create test/integration/plain_shell_rendering_test.rb\n\n```json:metadata\n{\"files\": [\"test/integration/plain_shell_rendering_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration/plain_shell_rendering_test.rb\", \"acceptanceCriteria\": [\"plain rail-less e2e\", \"modern unchanged\", \"shell restored\"], \"requiresUserVerification\": false}\n```"
29
+ },
30
+ {
31
+ "id": 5,
32
+ "subject": "Task 5: Named current_<account> Rodauth accessor",
33
+ "status": "completed",
34
+ "description": "**Goal:** Rodauth(name) exposes current_<name> aliased to current_user, both helper methods.\n\n**Files:** Modify lib/plutonium/auth/rodauth.rb; modify test/plutonium/auth/rodauth_test.rb\n\n```json:metadata\n{\"files\": [\"lib/plutonium/auth/rodauth.rb\", \"test/plutonium/auth/rodauth_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/auth/rodauth_test.rb\", \"acceptanceCriteria\": [\"named accessor helper\", \"same account\", \"user name no-op safe\"], \"requiresUserVerification\": false}\n```"
35
+ },
36
+ {
37
+ "id": 6,
38
+ "subject": "Task 6: Docs + config comments",
39
+ "status": "completed",
40
+ "blockedBy": [1, 2, 3, 5],
41
+ "description": "**Goal:** Document :plain shell, rail DSL, named accessor; comment shell config sites.\n\n**Files:** Modify lib/plutonium/configuration.rb, lib/generators/pu/core/install/templates/config/initializers/plutonium.rb, docs/, .claude/skills/\n\n```json:metadata\n{\"files\": [\"lib/plutonium/configuration.rb\", \"lib/generators/pu/core/install/templates/config/initializers/plutonium.rb\", \"docs\", \".claude/skills\"], \"verifyCommand\": \"yarn docs:build\", \"acceptanceCriteria\": [\"config doc\", \"initializer comment\", \"docs+skills updated\"], \"requiresUserVerification\": false}\n```"
42
+ },
43
+ {
44
+ "id": 7,
45
+ "subject": "Task 7: Fix final-review findings (pu-no-rail Turbo nav + plain offset)",
46
+ "status": "completed",
47
+ "description": "**Goal:** From the final holistic review: (1) the turbo:before-render listener in base.rb now toggles pu-no-rail (inverse of hasRail, gated to non-classic shells) so the class stays consistent across Turbo navigations; (2) main_attributes keys the modern-family offset off rail? so :plain+rail true gets lg:pl-20. Commit f60bb5cd.\n\n**Files:** lib/plutonium/ui/layout/base.rb, lib/plutonium/ui/layout/resource_layout.rb, test/plutonium/ui/layout/resource_layout_rail_test.rb, test/integration/plain_shell_rendering_test.rb\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/layout/base.rb\", \"lib/plutonium/ui/layout/resource_layout.rb\", \"test/plutonium/ui/layout/resource_layout_rail_test.rb\", \"test/integration/plain_shell_rendering_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 rake test\", \"acceptanceCriteria\": [\"listener toggles pu-no-rail (non-classic)\", \"plain+rail true gets lg:pl-20\"], \"requiresUserVerification\": false}\n```"
48
+ }
49
+ ],
50
+ "lastUpdated": "2026-06-15T00:00:00Z"
51
+ }
@@ -146,9 +146,9 @@ heading/description/wrapper so they're themeable like the rest of the form.
146
146
  hidden**. It renders through the normal path with defaults (its default/declared
147
147
  chrome). There is no automatic empty-hiding; to hide a section conditionally,
148
148
  use `condition:`.
149
- - **Unknown field key in a `section`** (not an attribute at all) raise at
150
- render with a clear message (catches typos), consistent with how the form
151
- already errors on unknown fields.
149
+ - **Field key in a `section` not in the permitted set** (a typo, or filtered by
150
+ policy / per-action / scoping / nesting) **silently skipped**, never an
151
+ error. _(Amended originally raised; see Amendments.)_
152
152
  - **`condition` falsey** → section renders nothing; its fields do **not** spill
153
153
  into `ungrouped` (they remain owned by the suppressed section).
154
154
  - **No leftovers** → `ungrouped` renders with defaults (with no fields and no
@@ -175,8 +175,8 @@ heading/description/wrapper so they're themeable like the rest of the form.
175
175
  - **Assignment:** fields land in the right section in declared order; leftovers
176
176
  collect into `ungrouped`; `ungrouped` default position is last; explicit
177
177
  position honored.
178
- - **Filtering:** policy-filtered field is skipped; an empty section still renders
179
- with defaults (is **not** hidden); unknown field key raises.
178
+ - **Filtering:** a field not in the permitted set (policy-filtered or a typo) is
179
+ skipped; an empty section still renders with defaults (is **not** hidden).
180
180
  - **Conditions:** falsey `condition` hides the section and withholds its fields.
181
181
  - **Rendering:** headings/descriptions present; collapsible emits
182
182
  `<details>`/`<summary>` with correct `open`; `columns:` changes grid classes.
@@ -221,6 +221,16 @@ Changes made after the original plan landed:
221
221
  evaluation in one pass); `render_form_section` is pure presentation. `columns:`
222
222
  stays a validated literal (it feeds the grid class).
223
223
 
224
+ - **Unknown / filtered field keys are skipped, not raised.** A `section` key not
225
+ in the form's permitted set (`submittable_attributes_for(action)`) is silently
226
+ dropped instead of raising `ArgumentError`. The original raise couldn't tell a
227
+ typo from a field that's simply not permitted in the current context (per-action
228
+ `permitted_attributes`, entity scoping, nesting, per-user policy), so it crashed
229
+ forms that referenced conditionally-permitted fields. Skipping makes one
230
+ `form_layout` safe across all those contexts. (`resolve_form_sections` only ever
231
+ saw the filtered list, so it could never reliably distinguish typo from filtered
232
+ anyway.)
233
+
224
234
  - **Interactions: verified + exercised.** `Form::Interaction < Form::Resource`
225
235
  already inherited the layout path; this is now covered by a dummy interaction
226
236
  (`ReconfigureKitchenSink`, a record action on `KitchenSink`) and an integration
@@ -0,0 +1,275 @@
1
+ # Railless portal support — design
2
+
3
+ **Date:** 2026-06-14
4
+ **Status:** Approved (pending spec review)
5
+ **Source:** Bug report — "Plutonium 0.60.0 — railless portal gets phantom icon-rail offsets"
6
+
7
+ ## Problem
8
+
9
+ A portal that intentionally omits the icon rail (a supported customization)
10
+ still inherits layout offsets that assume the 56px icon rail is always present
11
+ on desktop (≥1024px). Symptoms:
12
+
13
+ 1. Main content pushed ~15.5rem right (empty left gutter / "tilt").
14
+ 2. Form sticky action footer has a 56px dead gap on its left edge.
15
+
16
+ Both stem from one assumption — *the icon rail always exists* — leaking into
17
+ four places, with no first-class "no rail" opt-out:
18
+
19
+ | # | Source | Footprint |
20
+ |---|--------|-----------|
21
+ | 1 | `ResourceLayout#render_pre_paint_scripts` adds `pu-rail-pinned` on initial load (gated only on a `localStorage` flag) → CSS `html.pu-rail-pinned main { padding-left: 15.5rem !important }` | 15.5rem main offset |
22
+ | 2 | `ResourceLayout#main_attributes` `lg:pl-20` (collapsed-rail 80px offset, applied even when unpinned) | 5rem main offset |
23
+ | 3 | `Topbar` `lg:left-14` | 56px topbar inset |
24
+ | 4 | `StickyFooter` `lg:left-14` | 56px footer inset |
25
+
26
+ Key existing asymmetry: the `turbo:before-render` listener in
27
+ `Base#render_pre_paint_scripts` *already* keys `pu-rail-pinned` on
28
+ `newBody.querySelector('[data-controller~="icon-rail"]')` — i.e. on actual rail
29
+ presence. Only the **initial-load** path and the static CSS/utility classes
30
+ hardcode the assumption.
31
+
32
+ ## Goals
33
+
34
+ - First-class "no rail" mode, resolvable globally **and** per-portal/per-controller.
35
+ - One source of truth so all four offsets stay consistent.
36
+ - Stable hooks on `Topbar`/`StickyFooter` for consumer overrides (the report's ask).
37
+ - Zero behavior change for existing `:modern` (railed) apps. Low regression risk.
38
+
39
+ ## Non-goals
40
+
41
+ - No full CSS refactor of the rail system (rejected Approach C — largest/riskiest diff).
42
+ - No branding solution for the rail-less topbar (the brand mark lives in
43
+ `IconRail`'s `with_brand` slot today; `:plain`-shell users add branding to
44
+ their ejected header). Flagged as a follow-up, out of scope here.
45
+ - `:classic` shell behavior is preserved unchanged (see Classic shell note).
46
+
47
+ ## Design — Approach A: `rail?` predicate as single source of truth
48
+
49
+ A single boolean, `rail?`, resolved through three tiers (each overrides the one
50
+ above), drives every offset. Per the decision: implement the **config** tier and
51
+ the **engine/controller** tier; the layout simply delegates to the controller.
52
+
53
+ ### Tier 1 — Global: `config.shell`
54
+
55
+ `lib/plutonium/configuration.rb`
56
+
57
+ - Recognize a new shell value `:plain` (rail-less modern shell). `:modern`
58
+ keeps today's rail. No validation is added (shell is already a permissive
59
+ `attr_accessor`); `:plain` is just supported everywhere shell is consumed.
60
+
61
+ ```ruby
62
+ config.shell = :plain # whole app rail-less by default
63
+ ```
64
+
65
+ ### Tier 2 — Controller: `rail` DSL (primary per-scope hook)
66
+
67
+ `lib/plutonium/core/controller.rb` (the concern that sets `layout "resource"`,
68
+ so it is included in every controller that renders `ResourceLayout`).
69
+
70
+ ```ruby
71
+ included do
72
+ class_attribute :_rail_enabled, instance_writer: false, default: nil
73
+ helper_method :rail?
74
+ end
75
+
76
+ class_methods do
77
+ def rail(enabled)
78
+ self._rail_enabled = enabled
79
+ end
80
+ end
81
+
82
+ # nil = inherit the shell default; true/false = explicit override
83
+ def rail?
84
+ return _rail_enabled unless _rail_enabled.nil?
85
+ Plutonium.configuration.shell == :modern
86
+ end
87
+ ```
88
+
89
+ Because `_rail_enabled` is a `class_attribute`, it is **inherited**. A portal
90
+ opts its whole surface in/out by setting it once in its engine's controller
91
+ concern — no view ejection:
92
+
93
+ ```ruby
94
+ module CustomerPortal
95
+ module Concerns
96
+ module Controller
97
+ extend ActiveSupport::Concern
98
+ included { rail false } # entire portal rail-less
99
+ end
100
+ end
101
+ end
102
+ ```
103
+
104
+ A single controller can flip it for just its resource. `nil` means "inherit",
105
+ so leaving it unset falls through to the shell default with no accidental
106
+ override.
107
+
108
+ ### Tier 3 — Layout: delegate to the controller
109
+
110
+ `lib/plutonium/ui/layout/resource_layout.rb`
111
+
112
+ ```ruby
113
+ def rail? = controller.rail?
114
+ ```
115
+
116
+ `controller` is already available in the layout component (used for
117
+ `@page_title`). A layout subclass *can* still override `rail?` for pure
118
+ view-level logic, but the controller tier covers the portal and per-resource
119
+ cases without ejecting a layout view.
120
+
121
+ ### What `rail?` gates
122
+
123
+ `ResourceLayout`:
124
+
125
+ ```ruby
126
+ def render_before_main
127
+ super
128
+ render partial("resource_header")
129
+ render partial("resource_sidebar") if rail? # skip the IconRail when rail-less
130
+ end
131
+
132
+ def render_pre_paint_scripts
133
+ super
134
+ return unless rail? # no initial pu-rail-pinned when rail-less
135
+ script { ... existing initial-load pin ... }
136
+ end
137
+
138
+ def main_attributes
139
+ classes = case Plutonium.configuration.shell
140
+ when :modern
141
+ rail? ? "pt-16 pb-6 px-6 lg:pl-20" : "pt-16 pb-6 px-6"
142
+ when :plain
143
+ "pt-16 pb-6 px-6"
144
+ else # :classic — unchanged
145
+ "pt-20 lg:ml-64"
146
+ end
147
+ mix(super, {class: classes})
148
+ end
149
+
150
+ # add a server-rendered root class (no FOUC) so decoupled fixed components
151
+ # can cancel their rail insets. Scoped to the modern family so :classic is
152
+ # byte-for-byte unchanged.
153
+ def html_attributes
154
+ attrs = super
155
+ return attrs if Plutonium.configuration.shell == :classic
156
+ rail? ? attrs : mix(attrs, {class: "pu-no-rail"})
157
+ end
158
+ ```
159
+
160
+ ### Decoupled components — stable hooks + CSS cancel
161
+
162
+ `Topbar` and `StickyFooter` are rendered outside the layout's reach (Topbar from
163
+ a partial, StickyFooter from every form), so they can't read `rail?` directly.
164
+ They keep their `lg:left-14` inset and gain a **stable class** so CSS keyed on
165
+ the root `pu-no-rail` class can cancel the inset — this also satisfies the
166
+ report's "stable hook" request.
167
+
168
+ - `lib/plutonium/ui/layout/topbar.rb` — add `pu-topbar` to the `nav` class.
169
+ - `lib/plutonium/ui/form/components/sticky_footer.rb` — add `pu-sticky-footer`
170
+ to the `div` class.
171
+
172
+ `src/css/components.css`:
173
+
174
+ ```css
175
+ @media (min-width: 1024px) {
176
+ html.pu-no-rail .pu-topbar,
177
+ html.pu-no-rail .pu-sticky-footer {
178
+ left: 0 !important;
179
+ }
180
+ }
181
+ ```
182
+
183
+ No CSS change is needed for `main`: the 15.5rem pinned padding only applies when
184
+ `pu-rail-pinned` is present (never added when rail-less), and the collapsed
185
+ `lg:pl-20` is dropped in `main_attributes`.
186
+
187
+ > **Asset build:** `src/css/components.css` is the source; the shipped
188
+ > `app/assets/plutonium.css` (and `.min`) must be rebuilt with `yarn build`
189
+ > (`yarn dev` while developing).
190
+
191
+ ### Cross-navigation correctness (already handled)
192
+
193
+ The `turbo:before-render` listener in `Base` keys `pu-rail-pinned` on
194
+ `querySelector('[data-controller~="icon-rail"]')`. A rail-less page renders no
195
+ `icon-rail` controller, so navigating rail → rail-less correctly drops the pin,
196
+ matching the server-rendered `pu-no-rail`. No JS change required.
197
+
198
+ ### Classic shell note
199
+
200
+ `:classic` is preserved unchanged: its `main_attributes` branch is untouched,
201
+ `pu-no-rail` is never emitted for it, and the initial-load pin is now gated on
202
+ `rail?` (false for classic) — which also removes a previously latent spurious
203
+ `pu-rail-pinned` emission for classic apps. Net: strictly equal or better.
204
+
205
+ ## Auth: named `current_<account>` accessor (folds in the report appendix)
206
+
207
+ **Finding:** the report's appendix is *not* a generator bug. The portal
208
+ generator (`concerns/controller.rb.tt`) never emits `current_admin` /
209
+ `require_admin_role`, and `Plutonium::Auth::Rodauth(name)`
210
+ (`lib/plutonium/auth/rodauth.rb`) deliberately exposes `current_user`
211
+ (= `rodauth(name).rails_account`) regardless of account name. The `current_admin`
212
+ in the report is hand-written app code; calling it raises `NoMethodError`.
213
+
214
+ **Improvement:** expose a **named** accessor alongside `current_user` so
215
+ multi-account code reads naturally and an admin session is distinguishable from
216
+ a user session.
217
+
218
+ `lib/plutonium/auth/rodauth.rb` — in addition to `current_user`, define
219
+ `current_<name>` aliased to the same `rails_account`, and register it as a
220
+ helper method:
221
+
222
+ ```ruby
223
+ included do
224
+ helper_method :current_user, :current_#{name}
225
+ helper_method :logout_url, :profile_url
226
+ end
227
+
228
+ def current_user
229
+ rodauth.rails_account
230
+ end
231
+ alias_method :current_#{name}, :current_user
232
+ ```
233
+
234
+ So `include Plutonium::Auth::Rodauth(:admin)` yields both `current_user` and
235
+ `current_admin`. Additive, backward-compatible. (When `name == :user`,
236
+ `current_#{name}` is just `current_user` — harmless re-definition.)
237
+
238
+ Docs: note the correct pattern and the new named accessor in the auth guide /
239
+ `plutonium-auth` skill.
240
+
241
+ ## Files touched
242
+
243
+ - `lib/plutonium/configuration.rb` — support `:plain` shell (doc/comments).
244
+ - `lib/plutonium/core/controller.rb` — `rail` DSL + `rail?` + `helper_method`.
245
+ - `lib/plutonium/ui/layout/resource_layout.rb` — `rail?` delegate; gate
246
+ `render_before_main`, `render_pre_paint_scripts`, `main_attributes`,
247
+ `html_attributes`.
248
+ - `lib/plutonium/ui/layout/topbar.rb` — `pu-topbar` class.
249
+ - `lib/plutonium/ui/form/components/sticky_footer.rb` — `pu-sticky-footer` class.
250
+ - `src/css/components.css` — `html.pu-no-rail` inset cancel; rebuild assets.
251
+ - `lib/plutonium/auth/rodauth.rb` — named `current_<account>` accessor.
252
+ - Docs: shell/`:plain` + `rail` DSL (UI/app guide, `plutonium-ui`/`plutonium-app`
253
+ skills); named accessor (`plutonium-auth` skill).
254
+
255
+ ## Testing strategy
256
+
257
+ - **Controller resolution** (unit): `rail?` returns `true` for `:modern`,
258
+ `false` for `:plain`/`:classic`; `rail true`/`rail false` override the shell
259
+ default; `nil` inherits; the class_attribute inherits to subclasses.
260
+ - **Layout rendering** (component/integration): with `rail?` false — sidebar
261
+ partial absent, no `pu-rail-pinned` script, no `lg:pl-20` on `main`,
262
+ `pu-no-rail` present on `<html>`. With `rail?` true — current markup unchanged.
263
+ - **Dummy app**: add a controller/portal that sets `rail false`; assert the
264
+ rendered page has `pu-no-rail` and the form's sticky footer carries
265
+ `pu-sticky-footer` (so the CSS cancel applies). Use the existing
266
+ `plutonium-testing` toolkit.
267
+ - **Auth** (unit): `Plutonium::Auth::Rodauth(:admin)` exposes both
268
+ `current_user` and `current_admin` returning the same account.
269
+ - Run across Rails 7 / 8.0 / 8.1 via Appraisal.
270
+
271
+ ## Rollout / compatibility
272
+
273
+ Fully additive. Existing `:modern` apps emit no `pu-no-rail` and render
274
+ identical markup. `:plain` is opt-in via config; `rail false` is opt-in per
275
+ controller/portal. Asset rebuild required for the CSS rule to ship.
@@ -3,6 +3,7 @@
3
3
  Plutonium.configure do |config|
4
4
  config.load_defaults 1.0
5
5
 
6
+ # Shell variant: :modern (icon rail), :plain (no rail), or :classic (legacy).
6
7
  config.shell = :modern
7
8
  # Configure plutonium above.
8
9
  end
@@ -7,7 +7,7 @@ module Plutonium
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- helper_method :current_user
10
+ helper_method :current_user, :current_#{name}
11
11
  helper_method :logout_url
12
12
  helper_method :profile_url
13
13
  end
@@ -23,6 +23,7 @@ module Plutonium
23
23
  def current_user
24
24
  rodauth.rails_account
25
25
  end
26
+ alias_method :current_#{name}, :current_user
26
27
 
27
28
  def logout_url
28
29
  rodauth.logout_path
@@ -27,7 +27,8 @@ module Plutonium
27
27
  # @return [Float] the current defaults version
28
28
  attr_reader :defaults_version
29
29
 
30
- # @return [Symbol] :classic (legacy Header/Sidebar) or :modern (Topbar/IconRail)
30
+ # @return [Symbol] :modern (Topbar/IconRail, default), :plain (Topbar, no
31
+ # icon rail), or :classic (legacy Header/Sidebar).
31
32
  attr_accessor :shell
32
33
 
33
34
  # @return [String] host URL of the Navii avatar service (no path), used by
@@ -45,6 +45,44 @@ module Plutonium
45
45
  append_view_path File.expand_path("app/views", Plutonium.root)
46
46
  layout -> { turbo_frame_request? ? false : "resource" }
47
47
  helper_method :registered_resources
48
+
49
+ class_attribute :_rail_enabled, instance_writer: false, default: nil
50
+ class_attribute :_shell, instance_writer: false, default: nil
51
+ helper_method :rail?, :shell
52
+ end
53
+
54
+ class_methods do
55
+ # Enable or disable the modern icon rail for this controller and its
56
+ # subclasses. nil (the default) inherits the global shell default.
57
+ def rail(enabled)
58
+ self._rail_enabled = enabled
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
66
+ end
67
+
68
+ # Whether the modern icon rail is active for this request. Resolves the
69
+ # per-controller `rail` setting, falling back to the shell default.
70
+ # Public: the resource layout calls `controller.rail?`.
71
+ def rail?
72
+ return _rail_enabled unless _rail_enabled.nil?
73
+ shell == :modern
74
+ end
75
+
76
+ # Resolved shell variant for this request: controller override, else the
77
+ # engine's shell, else the global Plutonium.configuration.shell.
78
+ def shell
79
+ return _shell unless _shell.nil?
80
+
81
+ engine = current_engine
82
+ engine_shell = engine.shell unless engine == Rails.application.class
83
+ return engine_shell unless engine_shell.nil?
84
+
85
+ Plutonium.configuration.shell
48
86
  end
49
87
 
50
88
  private
@@ -105,16 +105,15 @@ module Plutonium
105
105
  resource_fields = resource_fields.map(&:to_sym)
106
106
  known = resource_fields.to_set
107
107
 
108
- # First-section-wins assignment: map each field to the first section key.
108
+ # First-section-wins assignment: each field is claimed by the first
109
+ # section that lists it. A field a section lists but that isn't in the
110
+ # currently-permitted set (policy, per-action, entity scoping, nesting)
111
+ # is simply skipped — it never renders and is never an error.
109
112
  owner = {}
110
113
  layout.each do |section|
111
114
  next if section.ungrouped?
112
115
  section.fields.each do |f|
113
- unless known.include?(f)
114
- raise ArgumentError,
115
- "form_layout section :#{section.key} references unknown field :#{f}"
116
- end
117
- owner[f] ||= section.key
116
+ owner[f] ||= section.key if known.include?(f)
118
117
  end
119
118
  end
120
119
  leftovers = resource_fields.reject { |f| owner.key?(f) }
@@ -35,6 +35,16 @@ module Plutonium
35
35
  end
36
36
  end
37
37
  end
38
+
39
+ class_methods do
40
+ # Shell variant for this engine's controllers (:modern / :plain /
41
+ # :classic). Overrides the global Plutonium.configuration.shell and is
42
+ # itself overridden by a controller's own `shell`. Unset (nil) inherits.
43
+ def shell(value = nil)
44
+ @shell = value unless value.nil?
45
+ @shell
46
+ end
47
+ end
38
48
  end
39
49
  end
40
50
  end
@@ -6,7 +6,7 @@ module Plutonium
6
6
  module Components
7
7
  class StickyFooter < Plutonium::UI::Component::Base
8
8
  def view_template(&block)
9
- div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
9
+ div(class: "pu-sticky-footer fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
10
10
  "h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
11
11
  "px-6 flex items-center justify-end gap-2", &block)
12
12
  end
@@ -48,7 +48,11 @@ module Plutonium
48
48
  # on <html> based on whether the incoming body contains an icon-rail,
49
49
  # preventing layout shift on Turbo navigations between rail and non-rail
50
50
  # pages. Initial-load rail-pinned is handled by ResourceLayout.
51
+ # The same listener also keeps `pu-no-rail` in sync (inverse of the rail
52
+ # signal) for non-classic shells, since Turbo Drive does not update
53
+ # <html> attributes across cross-URL visits.
51
54
  def render_pre_paint_scripts
55
+ manage_no_rail = pre_paint_shell != :classic
52
56
  script do
53
57
  raw(safe(<<~JS))
54
58
  (function () {
@@ -68,6 +72,7 @@ module Plutonium
68
72
  } else {
69
73
  document.documentElement.classList.remove("pu-rail-pinned");
70
74
  }
75
+ #{'document.documentElement.classList.toggle("pu-no-rail", !hasRail);' if manage_no_rail}
71
76
  });
72
77
  } catch (e) {}
73
78
  })();
@@ -75,6 +80,11 @@ module Plutonium
75
80
  end
76
81
  end
77
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
+
78
88
  def render_body(&)
79
89
  body(**body_attributes) {
80
90
  render_before_main
@@ -4,11 +4,24 @@ module Plutonium
4
4
  class ResourceLayout < Base
5
5
  private
6
6
 
7
+ # Whether the modern icon rail is active for this request. Delegates to
8
+ # the controller's resolution (shell default + per-controller `rail`).
9
+ def rail? = controller.rail?
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
+
7
17
  # Sets pu-rail-pinned immediately on initial page load so the rail
8
18
  # renders in its pinned state from the first frame. Turbo navigations
9
- # are handled by the turbo:before-render listener in Base.
19
+ # are handled by the turbo:before-render listener in Base. Skipped
20
+ # entirely when the layout renders no rail.
10
21
  def render_pre_paint_scripts
11
22
  super
23
+ return unless rail?
24
+
12
25
  script do
13
26
  raw(safe(<<~JS))
14
27
  (function () {
@@ -22,12 +35,21 @@ module Plutonium
22
35
  end
23
36
  end
24
37
 
38
+ # Adds `pu-no-rail` to <html> (server-side, no FOUC) so decoupled fixed
39
+ # chrome (Topbar, form StickyFooter) can cancel their rail insets.
40
+ # Scoped to the modern family — :classic keeps its own offsets.
41
+ def html_attributes
42
+ attrs = super
43
+ return attrs if shell == :classic
44
+
45
+ rail? ? attrs : mix(attrs, {class: "pu-no-rail"})
46
+ end
47
+
25
48
  def main_attributes
26
- classes = case Plutonium.configuration.shell
27
- when :modern
28
- "pt-16 pb-6 px-6 lg:pl-20"
29
- else
49
+ classes = if shell == :classic
30
50
  "pt-20 lg:ml-64"
51
+ else
52
+ rail? ? "pt-16 pb-6 px-6 lg:pl-20" : "pt-16 pb-6 px-6"
31
53
  end
32
54
 
33
55
  mix(super, {class: classes})
@@ -43,7 +65,7 @@ module Plutonium
43
65
  super
44
66
 
45
67
  render partial("resource_header")
46
- render partial("resource_sidebar")
68
+ render partial("resource_sidebar") if rail?
47
69
  end
48
70
  end
49
71
  end
@@ -33,7 +33,7 @@ module Plutonium
33
33
 
34
34
  def view_template
35
35
  nav(
36
- class: "fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
36
+ class: "pu-topbar fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
37
37
  "bg-[var(--pu-surface)] border-b border-[var(--pu-border)] " \
38
38
  "flex items-center gap-3 px-4",
39
39
  data: {
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.60.0"
2
+ VERSION = "0.60.2"
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.0",
3
+ "version": "0.60.2",
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",
@@ -757,6 +757,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
757
757
  }
758
758
  }
759
759
 
760
+ /* Rail-less layouts (html.pu-no-rail): cancel the icon-rail inset on fixed
761
+ chrome so the topbar and form sticky footer span the full width. */
762
+ @media (min-width: 1024px) {
763
+ html.pu-no-rail .pu-topbar,
764
+ html.pu-no-rail .pu-sticky-footer {
765
+ left: 0 !important;
766
+ }
767
+ }
768
+
760
769
  /* Flyout: visibility controlled by Stimulus (data-flyout-open) via position:fixed */
761
770
  .icon-rail-parent {
762
771
  position: relative;