plutonium 0.60.0 → 0.60.1

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,761 @@
1
+ # Railless Portal Support — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a first-class "no rail" mode so a portal can omit the modern icon rail without inheriting its desktop layout offsets, resolvable globally (`config.shell = :plain`) and per-controller (`rail false`).
6
+
7
+ **Architecture:** A single `rail?` predicate resolved through three tiers — global shell default → controller `rail` DSL (inherited) → layout delegates to the controller. `rail?` gates the sidebar partial, the initial-load `pu-rail-pinned` script, the `main` content offset, and a server-rendered `pu-no-rail` root class. `Topbar`/`StickyFooter` gain stable hook classes and a CSS rule keyed on `html.pu-no-rail` cancels their rail insets.
8
+
9
+ **Tech Stack:** Ruby, Rails (7 / 8.0 / 8.1 via Appraisal), Phlex components, TailwindCSS 4 (esbuild via `yarn build`), Minitest.
10
+
11
+ **User Verification:** NO — no user verification required.
12
+
13
+ > **Commit policy:** The repo owner's standing rule is *do not stage or commit unless explicitly asked*. The `git commit` steps below are written for completeness; **skip them unless the user has explicitly authorized committing.** Run the verification (test) steps regardless.
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ | File | Responsibility | Change |
20
+ |------|----------------|--------|
21
+ | `lib/plutonium/core/controller.rb` | Controller mixin; owns `rail` DSL + `rail?` resolution | Modify |
22
+ | `lib/plutonium/ui/layout/resource_layout.rb` | Resource page layout; gates rail-dependent rendering | Modify |
23
+ | `lib/plutonium/ui/layout/topbar.rb` | Fixed topbar; gains `pu-topbar` hook | Modify |
24
+ | `lib/plutonium/ui/form/components/sticky_footer.rb` | Form action footer; gains `pu-sticky-footer` hook | Modify |
25
+ | `src/css/components.css` | Source CSS; `html.pu-no-rail` inset cancel | Modify |
26
+ | `app/assets/plutonium.css` / `.min.js` | Built assets | Regenerated by `yarn build` |
27
+ | `lib/plutonium/auth/rodauth.rb` | Rodauth auth mixin; named `current_<account>` accessor | Modify |
28
+ | `lib/plutonium/configuration.rb` | Config; doc `:plain` shell | Modify (comment) |
29
+ | `lib/generators/pu/core/install/templates/config/initializers/plutonium.rb` | Install initializer; doc `:plain` | Modify (comment) |
30
+ | `test/plutonium/core/controller_rail_test.rb` | Unit: `rail?` resolution + DSL | Create |
31
+ | `test/plutonium/ui/layout/resource_layout_rail_test.rb` | Unit: `main_attributes` / `html_attributes` gating | Create |
32
+ | `test/plutonium/ui/layout/topbar_test.rb` | Unit: `pu-topbar` hook | Modify |
33
+ | `test/plutonium/ui/form/components/sticky_footer_test.rb` | Unit: `pu-sticky-footer` hook | Modify |
34
+ | `test/integration/plain_shell_rendering_test.rb` | Integration: end-to-end `:plain` vs `:modern` | Create |
35
+ | `test/plutonium/auth/rodauth_test.rb` | Unit: named accessor | Modify |
36
+ | `docs/reference/...` + `.claude/skills/...` | Docs for `:plain` / `rail` / named accessor | Modify |
37
+
38
+ ---
39
+
40
+ ## Task 1: Controller `rail` DSL + `rail?` resolution
41
+
42
+ **Goal:** Add an inherited `rail` class DSL and a `rail?` predicate to the controller mixin, defaulting from `config.shell`.
43
+
44
+ **Files:**
45
+ - Modify: `lib/plutonium/core/controller.rb`
46
+ - Test: `test/plutonium/core/controller_rail_test.rb` (create)
47
+
48
+ **Acceptance Criteria:**
49
+ - [ ] `rail?` returns `true` when `config.shell == :modern`, `false` for `:plain` and `:classic`.
50
+ - [ ] `rail true` / `rail false` override the shell default; `nil` (default) inherits it.
51
+ - [ ] `_rail_enabled` is a `class_attribute` (inherits to subclasses; subclass can re-set).
52
+ - [ ] `rail?` is registered as a helper method and is public (the layout calls `controller.rail?`).
53
+
54
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/core/controller_rail_test.rb` → all pass.
55
+
56
+ **Steps:**
57
+
58
+ - [ ] **Step 1: Write the failing test**
59
+
60
+ Create `test/plutonium/core/controller_rail_test.rb`:
61
+
62
+ ```ruby
63
+ # frozen_string_literal: true
64
+
65
+ require "test_helper"
66
+
67
+ class Plutonium::Core::ControllerRailTest < ActiveSupport::TestCase
68
+ def build_controller_class
69
+ Class.new(ActionController::Base) { include Plutonium::Core::Controller }
70
+ end
71
+
72
+ def with_shell(value)
73
+ original = Plutonium.configuration.shell
74
+ Plutonium.configuration.shell = value
75
+ yield
76
+ ensure
77
+ Plutonium.configuration.shell = original
78
+ end
79
+
80
+ test "rail? defaults to true for the modern shell" do
81
+ with_shell(:modern) do
82
+ assert build_controller_class.new.rail?
83
+ end
84
+ end
85
+
86
+ test "rail? defaults to false for the plain shell" do
87
+ with_shell(:plain) do
88
+ refute build_controller_class.new.rail?
89
+ end
90
+ end
91
+
92
+ test "rail? defaults to false for the classic shell" do
93
+ with_shell(:classic) do
94
+ refute build_controller_class.new.rail?
95
+ end
96
+ end
97
+
98
+ test "rail false overrides the modern default" do
99
+ klass = build_controller_class
100
+ klass.rail false
101
+ with_shell(:modern) do
102
+ refute klass.new.rail?
103
+ end
104
+ end
105
+
106
+ test "rail true overrides the plain default" do
107
+ klass = build_controller_class
108
+ klass.rail true
109
+ with_shell(:plain) do
110
+ assert klass.new.rail?
111
+ end
112
+ end
113
+
114
+ test "rail setting inherits to subclasses" do
115
+ parent = build_controller_class
116
+ parent.rail false
117
+ child = Class.new(parent)
118
+ with_shell(:modern) do
119
+ refute child.new.rail?
120
+ end
121
+ end
122
+
123
+ test "rail? is registered as a helper method" do
124
+ assert_includes build_controller_class._helper_methods, :rail?
125
+ end
126
+
127
+ test "rail? is callable as a public method" do
128
+ assert_respond_to build_controller_class.new, :rail?
129
+ end
130
+ end
131
+ ```
132
+
133
+ - [ ] **Step 2: Run test to verify it fails**
134
+
135
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/core/controller_rail_test.rb`
136
+ Expected: FAIL — `NoMethodError: undefined method 'rail'` / `rail?`.
137
+
138
+ - [ ] **Step 3: Add the DSL and predicate**
139
+
140
+ In `lib/plutonium/core/controller.rb`, inside the existing `included do` block (after `helper_method :registered_resources`, before the block closes at line 48), add:
141
+
142
+ ```ruby
143
+ class_attribute :_rail_enabled, instance_writer: false, default: nil
144
+ helper_method :rail?
145
+ ```
146
+
147
+ Add a `class_methods do` block (place it just before the `private` keyword on line 50):
148
+
149
+ ```ruby
150
+ class_methods do
151
+ # Enable or disable the modern icon rail for this controller and its
152
+ # subclasses. nil (the default) inherits the global shell default.
153
+ def rail(enabled)
154
+ self._rail_enabled = enabled
155
+ end
156
+ end
157
+
158
+ # Whether the modern icon rail is active for this request. Resolves the
159
+ # per-controller `rail` setting, falling back to the shell default.
160
+ # Public: the resource layout calls `controller.rail?`.
161
+ def rail?
162
+ return _rail_enabled unless _rail_enabled.nil?
163
+ Plutonium.configuration.shell == :modern
164
+ end
165
+ ```
166
+
167
+ > Note: `rail?` is defined *above* the `private` keyword so it stays public.
168
+
169
+ - [ ] **Step 4: Run test to verify it passes**
170
+
171
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/core/controller_rail_test.rb`
172
+ Expected: PASS (8 tests).
173
+
174
+ - [ ] **Step 5: Commit** *(only if commits are authorized — see Commit policy)*
175
+
176
+ ```bash
177
+ git add lib/plutonium/core/controller.rb test/plutonium/core/controller_rail_test.rb
178
+ git commit -m "feat(layout): add controller rail DSL and rail? predicate"
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Task 2: Gate `ResourceLayout` rendering on `rail?`
184
+
185
+ **Goal:** Make `ResourceLayout` delegate `rail?` to the controller and skip every rail-dependent output (sidebar partial, initial pin script, `main` offset) when rail-less, emitting `pu-no-rail` on `<html>`.
186
+
187
+ **Files:**
188
+ - Modify: `lib/plutonium/ui/layout/resource_layout.rb`
189
+ - Test: `test/plutonium/ui/layout/resource_layout_rail_test.rb` (create)
190
+
191
+ **Acceptance Criteria:**
192
+ - [ ] `rail?` delegates to `controller.rail?`.
193
+ - [ ] `render_before_main` renders `resource_sidebar` only when `rail?`.
194
+ - [ ] `render_pre_paint_scripts` adds the `pu-rail-pinned` initial script only when `rail?`.
195
+ - [ ] `main_attributes` includes `lg:pl-20` only for modern+rail; `:plain` and modern-without-rail get no rail offset; `:classic` keeps `pt-20 lg:ml-64`.
196
+ - [ ] `html_attributes` adds class `pu-no-rail` when `!rail?` and shell is not `:classic`; otherwise unchanged.
197
+
198
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/resource_layout_rail_test.rb` → all pass.
199
+
200
+ **Steps:**
201
+
202
+ - [ ] **Step 1: Write the failing test**
203
+
204
+ Create `test/plutonium/ui/layout/resource_layout_rail_test.rb`:
205
+
206
+ ```ruby
207
+ # frozen_string_literal: true
208
+
209
+ require "test_helper"
210
+
211
+ class Plutonium::UI::Layout::ResourceLayoutRailTest < ActiveSupport::TestCase
212
+ def build_layout(rail:)
213
+ layout = Plutonium::UI::Layout::ResourceLayout.allocate
214
+ layout.define_singleton_method(:rail?) { rail }
215
+ layout
216
+ end
217
+
218
+ def with_shell(value)
219
+ original = Plutonium.configuration.shell
220
+ Plutonium.configuration.shell = value
221
+ yield
222
+ ensure
223
+ Plutonium.configuration.shell = original
224
+ end
225
+
226
+ test "main_attributes includes lg:pl-20 for modern with rail" do
227
+ with_shell(:modern) do
228
+ classes = build_layout(rail: true).send(:main_attributes)[:class]
229
+ assert_includes classes, "lg:pl-20"
230
+ end
231
+ end
232
+
233
+ test "main_attributes drops the rail offset for modern without rail" do
234
+ with_shell(:modern) do
235
+ classes = build_layout(rail: false).send(:main_attributes)[:class]
236
+ refute_includes classes, "lg:pl-20"
237
+ end
238
+ end
239
+
240
+ test "main_attributes has no rail offset for the plain shell" do
241
+ with_shell(:plain) do
242
+ classes = build_layout(rail: false).send(:main_attributes)[:class]
243
+ refute_includes classes, "lg:pl-20"
244
+ refute_includes classes, "lg:ml-64"
245
+ end
246
+ end
247
+
248
+ test "main_attributes keeps the classic sidebar offset" do
249
+ with_shell(:classic) do
250
+ classes = build_layout(rail: false).send(:main_attributes)[:class]
251
+ assert_includes classes, "lg:ml-64"
252
+ end
253
+ end
254
+
255
+ test "html_attributes adds pu-no-rail when rail-less (plain)" do
256
+ with_shell(:plain) do
257
+ attrs = build_layout(rail: false).send(:html_attributes)
258
+ assert_includes attrs[:class].to_s, "pu-no-rail"
259
+ end
260
+ end
261
+
262
+ test "html_attributes has no pu-no-rail when rail present" do
263
+ with_shell(:modern) do
264
+ attrs = build_layout(rail: true).send(:html_attributes)
265
+ refute_includes attrs[:class].to_s, "pu-no-rail"
266
+ end
267
+ end
268
+
269
+ test "html_attributes leaves classic shell untouched" do
270
+ with_shell(:classic) do
271
+ attrs = build_layout(rail: false).send(:html_attributes)
272
+ refute_includes attrs[:class].to_s, "pu-no-rail"
273
+ end
274
+ end
275
+ end
276
+ ```
277
+
278
+ - [ ] **Step 2: Run test to verify it fails**
279
+
280
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/resource_layout_rail_test.rb`
281
+ Expected: FAIL — `main_attributes` lacks the plain branch / no `html_attributes` override (no `pu-no-rail`).
282
+
283
+ - [ ] **Step 3: Update `ResourceLayout`**
284
+
285
+ Replace the body of `lib/plutonium/ui/layout/resource_layout.rb` (the `private` section) with:
286
+
287
+ ```ruby
288
+ class ResourceLayout < Base
289
+ private
290
+
291
+ # Whether the modern icon rail is active for this request. Delegates to
292
+ # the controller's resolution (shell default + per-controller `rail`).
293
+ def rail? = controller.rail?
294
+
295
+ # Sets pu-rail-pinned immediately on initial page load so the rail
296
+ # renders in its pinned state from the first frame. Turbo navigations
297
+ # are handled by the turbo:before-render listener in Base. Skipped
298
+ # entirely when the layout renders no rail.
299
+ def render_pre_paint_scripts
300
+ super
301
+ return unless rail?
302
+
303
+ script do
304
+ raw(safe(<<~JS))
305
+ (function () {
306
+ try {
307
+ if (localStorage.getItem("pu_rail_pinned") !== "false") {
308
+ document.documentElement.classList.add("pu-rail-pinned");
309
+ }
310
+ } catch (e) {}
311
+ })();
312
+ JS
313
+ end
314
+ end
315
+
316
+ # Adds `pu-no-rail` to <html> (server-side, no FOUC) so decoupled fixed
317
+ # chrome (Topbar, form StickyFooter) can cancel their rail insets.
318
+ # Scoped to the modern family — :classic keeps its own offsets.
319
+ def html_attributes
320
+ attrs = super
321
+ return attrs if Plutonium.configuration.shell == :classic
322
+
323
+ rail? ? attrs : mix(attrs, {class: "pu-no-rail"})
324
+ end
325
+
326
+ def main_attributes
327
+ classes = case Plutonium.configuration.shell
328
+ when :modern
329
+ rail? ? "pt-16 pb-6 px-6 lg:pl-20" : "pt-16 pb-6 px-6"
330
+ when :plain
331
+ "pt-16 pb-6 px-6"
332
+ else # :classic
333
+ "pt-20 lg:ml-64"
334
+ end
335
+
336
+ mix(super, {class: classes})
337
+ end
338
+
339
+ def page_title
340
+ make_page_title(
341
+ controller.instance_variable_get(:@page_title)
342
+ )
343
+ end
344
+
345
+ def render_before_main
346
+ super
347
+
348
+ render partial("resource_header")
349
+ render partial("resource_sidebar") if rail?
350
+ end
351
+ end
352
+ ```
353
+
354
+ - [ ] **Step 4: Run test to verify it passes**
355
+
356
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/resource_layout_rail_test.rb`
357
+ Expected: PASS (7 tests).
358
+
359
+ - [ ] **Step 5: Commit** *(only if authorized)*
360
+
361
+ ```bash
362
+ git add lib/plutonium/ui/layout/resource_layout.rb test/plutonium/ui/layout/resource_layout_rail_test.rb
363
+ git commit -m "feat(layout): gate rail rendering and offsets on rail?"
364
+ ```
365
+
366
+ ---
367
+
368
+ ## Task 3: Stable hook classes + `pu-no-rail` CSS + asset build
369
+
370
+ **Goal:** Give `Topbar` and `StickyFooter` stable classes and add a CSS rule that cancels their `lg:left-14` rail inset under `html.pu-no-rail`; rebuild assets.
371
+
372
+ **Files:**
373
+ - Modify: `lib/plutonium/ui/layout/topbar.rb` (line 36 nav class)
374
+ - Modify: `lib/plutonium/ui/form/components/sticky_footer.rb` (line 9 div class)
375
+ - Modify: `src/css/components.css` (after the `html.pu-rail-pinned main` rule, ~line 758)
376
+ - Modify (regenerated): `app/assets/plutonium.css`, `app/assets/plutonium.min.js` via `yarn build`
377
+ - Test: `test/plutonium/ui/layout/topbar_test.rb`, `test/plutonium/ui/form/components/sticky_footer_test.rb`
378
+
379
+ **Acceptance Criteria:**
380
+ - [ ] `Topbar` nav class contains `pu-topbar` (and still `lg:left-14`).
381
+ - [ ] `StickyFooter` div class contains `pu-sticky-footer` (and still `lg:left-14`).
382
+ - [ ] `src/css/components.css` cancels the inset to `left: 0` for both under `html.pu-no-rail` at `min-width: 1024px`.
383
+ - [ ] `app/assets/plutonium.css` rebuilt and contains `pu-no-rail`.
384
+
385
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb test/plutonium/ui/form/components/sticky_footer_test.rb` → pass; `grep -c "pu-no-rail" app/assets/plutonium.css` → ≥ 1.
386
+
387
+ **Steps:**
388
+
389
+ - [ ] **Step 1: Write the failing tests**
390
+
391
+ Append to `test/plutonium/ui/layout/topbar_test.rb` (before the final `end`):
392
+
393
+ ```ruby
394
+ test "nav has pu-topbar stable hook class" do
395
+ html = render_html(build_component)
396
+ assert_includes html, "pu-topbar"
397
+ end
398
+ ```
399
+
400
+ Append to `test/plutonium/ui/form/components/sticky_footer_test.rb` (before the final `end`):
401
+
402
+ ```ruby
403
+ def test_outer_div_has_pu_sticky_footer_stable_hook
404
+ outer_class = capture_outer_div_class
405
+ assert_includes outer_class, "pu-sticky-footer"
406
+ end
407
+ ```
408
+
409
+ - [ ] **Step 2: Run tests to verify they fail**
410
+
411
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb test/plutonium/ui/form/components/sticky_footer_test.rb`
412
+ Expected: FAIL — `pu-topbar` / `pu-sticky-footer` not present.
413
+
414
+ - [ ] **Step 3: Add the hook classes**
415
+
416
+ In `lib/plutonium/ui/layout/topbar.rb`, change the `nav` class string (line 36) from:
417
+
418
+ ```ruby
419
+ class: "fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
420
+ ```
421
+ to:
422
+ ```ruby
423
+ class: "pu-topbar fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
424
+ ```
425
+
426
+ In `lib/plutonium/ui/form/components/sticky_footer.rb`, change the `div` class (line 9) from:
427
+
428
+ ```ruby
429
+ div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
430
+ ```
431
+ to:
432
+ ```ruby
433
+ div(class: "pu-sticky-footer fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
434
+ ```
435
+
436
+ - [ ] **Step 4: Add the CSS rule**
437
+
438
+ In `src/css/components.css`, immediately after the existing block:
439
+
440
+ ```css
441
+ @media (min-width: 1024px) {
442
+ html.pu-rail-pinned main {
443
+ padding-left: 15.5rem !important;
444
+ }
445
+ }
446
+ ```
447
+
448
+ add:
449
+
450
+ ```css
451
+ /* Rail-less layouts (html.pu-no-rail): cancel the icon-rail inset on fixed
452
+ chrome so the topbar and form sticky footer span the full width. */
453
+ @media (min-width: 1024px) {
454
+ html.pu-no-rail .pu-topbar,
455
+ html.pu-no-rail .pu-sticky-footer {
456
+ left: 0 !important;
457
+ }
458
+ }
459
+ ```
460
+
461
+ - [ ] **Step 5: Rebuild assets**
462
+
463
+ Run: `yarn build`
464
+ Expected: rebuilds `app/assets/plutonium.css` and `app/assets/plutonium.min.js`.
465
+
466
+ - [ ] **Step 6: Verify tests and built asset**
467
+
468
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb test/plutonium/ui/form/components/sticky_footer_test.rb`
469
+ Expected: PASS.
470
+ Run: `grep -c "pu-no-rail" app/assets/plutonium.css`
471
+ Expected: ≥ 1.
472
+
473
+ - [ ] **Step 7: Commit** *(only if authorized)*
474
+
475
+ ```bash
476
+ git add 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
477
+ git commit -m "feat(layout): add pu-topbar/pu-sticky-footer hooks and pu-no-rail inset cancel"
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Task 4: End-to-end integration test (`:plain` vs `:modern`)
483
+
484
+ **Goal:** Prove a rail-less render omits the icon rail and pin script and carries `pu-no-rail`, while modern is unchanged.
485
+
486
+ **Files:**
487
+ - Test: `test/integration/plain_shell_rendering_test.rb` (create)
488
+
489
+ **Acceptance Criteria:**
490
+ - [ ] With `config.shell = :plain`: response has no `icon-rail` controller, no `classList.add("pu-rail-pinned")`, and `<html ... class="...pu-no-rail...">`.
491
+ - [ ] With `config.shell = :modern`: response includes `icon-rail` and the pin script.
492
+ - [ ] Shell config is restored in teardown.
493
+
494
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/plain_shell_rendering_test.rb` → all pass.
495
+
496
+ **Steps:**
497
+
498
+ - [ ] **Step 1: Write the integration test**
499
+
500
+ Create `test/integration/plain_shell_rendering_test.rb`:
501
+
502
+ ```ruby
503
+ # frozen_string_literal: true
504
+
505
+ require "test_helper"
506
+
507
+ class PlainShellRenderingTest < ActionDispatch::IntegrationTest
508
+ include IntegrationTestHelper
509
+
510
+ setup do
511
+ @admin = create_admin!
512
+ login_as_admin(@admin)
513
+ @original_shell = Plutonium.configuration.shell
514
+ end
515
+
516
+ teardown do
517
+ Plutonium.configuration.shell = @original_shell
518
+ end
519
+
520
+ test "plain shell renders rail-less" do
521
+ Plutonium.configuration.shell = :plain
522
+ get "/admin/kitchen_sinks"
523
+
524
+ assert_response :success
525
+ # NOTE: probe rail-gated-specific markers, not bare "icon-rail" /
526
+ # 'classList.add("pu-rail-pinned")' — those also appear in the
527
+ # always-present turbo:before-render listener in Base#render_pre_paint_scripts.
528
+ refute_includes response.body, 'data-controller="sidebar icon-rail"',
529
+ "plain shell should not render the icon rail"
530
+ refute_includes response.body, 'if (localStorage.getItem("pu_rail_pinned") !== "false") {',
531
+ "plain shell should not emit the initial pin script (Base listener's variant is prefixed `hasRail &&`)"
532
+ assert_match(/<html[^>]*class="[^"]*pu-no-rail/, response.body,
533
+ "plain shell should mark <html> with pu-no-rail")
534
+ end
535
+
536
+ test "modern shell still renders the icon rail and pin script" do
537
+ Plutonium.configuration.shell = :modern
538
+ get "/admin/kitchen_sinks"
539
+
540
+ assert_response :success
541
+ assert_includes response.body, 'data-controller="sidebar icon-rail"'
542
+ assert_includes response.body, 'if (localStorage.getItem("pu_rail_pinned") !== "false") {'
543
+ refute_match(/<html[^>]*class="[^"]*pu-no-rail/, response.body)
544
+ end
545
+ end
546
+ ```
547
+
548
+ - [ ] **Step 2: Run the test**
549
+
550
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/plain_shell_rendering_test.rb`
551
+ Expected: PASS (2 tests). If FAIL, the gating in Tasks 1–2 is incomplete — fix there, not here.
552
+
553
+ - [ ] **Step 3: Commit** *(only if authorized)*
554
+
555
+ ```bash
556
+ git add test/integration/plain_shell_rendering_test.rb
557
+ git commit -m "test(layout): integration coverage for plain (rail-less) shell"
558
+ ```
559
+
560
+ ---
561
+
562
+ ## Task 5: Named `current_<account>` Rodauth accessor
563
+
564
+ **Goal:** `Plutonium::Auth::Rodauth(name)` exposes `current_<name>` (aliased to `current_user`) in addition to `current_user`, both as helper methods.
565
+
566
+ **Files:**
567
+ - Modify: `lib/plutonium/auth/rodauth.rb`
568
+ - Test: `test/plutonium/auth/rodauth_test.rb`
569
+
570
+ **Acceptance Criteria:**
571
+ - [ ] `Rodauth.for(:admin)` registers both `:current_user` and `:current_admin` as helper methods.
572
+ - [ ] `current_admin` returns the same value as `current_user` (`rodauth.rails_account`).
573
+ - [ ] `Rodauth.for(:user)` still works (no duplicate-name breakage).
574
+
575
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/auth/rodauth_test.rb` → all pass.
576
+
577
+ **Steps:**
578
+
579
+ - [ ] **Step 1: Write the failing tests**
580
+
581
+ Append to `test/plutonium/auth/rodauth_test.rb` (before the `private` keyword):
582
+
583
+ ```ruby
584
+ test "module includes named current_<account> helper method" do
585
+ mod = Plutonium::Auth::Rodauth.for(:admin)
586
+ controller_class = build_controller_class(mod)
587
+
588
+ assert_includes controller_class._helper_methods, :current_admin
589
+ end
590
+
591
+ test "named accessor returns the same account as current_user" do
592
+ mod = Plutonium::Auth::Rodauth.for(:admin)
593
+ controller_class = build_controller_class(mod)
594
+ controller = controller_class.new
595
+
596
+ assert_equal controller.send(:current_user), controller.send(:current_admin)
597
+ end
598
+
599
+ test "for(:user) still defines current_user without error" do
600
+ mod = Plutonium::Auth::Rodauth.for(:user)
601
+ controller_class = build_controller_class(mod)
602
+
603
+ assert_includes controller_class._helper_methods, :current_user
604
+ end
605
+ ```
606
+
607
+ - [ ] **Step 2: Run tests to verify they fail**
608
+
609
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/auth/rodauth_test.rb`
610
+ Expected: FAIL — `current_admin` not a helper method.
611
+
612
+ - [ ] **Step 3: Add the named accessor**
613
+
614
+ In `lib/plutonium/auth/rodauth.rb`, update the heredoc. Change the `included do` helper registration from:
615
+
616
+ ```ruby
617
+ included do
618
+ helper_method :current_user
619
+ helper_method :logout_url
620
+ helper_method :profile_url
621
+ end
622
+ ```
623
+ to:
624
+ ```ruby
625
+ included do
626
+ helper_method :current_user, :current_#{name}
627
+ helper_method :logout_url
628
+ helper_method :profile_url
629
+ end
630
+ ```
631
+
632
+ And after the `current_user` definition:
633
+
634
+ ```ruby
635
+ def current_user
636
+ rodauth.rails_account
637
+ end
638
+ ```
639
+ add:
640
+ ```ruby
641
+ alias_method :current_#{name}, :current_user
642
+ ```
643
+
644
+ > When `name == :user`, `current_#{name}` is just `current_user` — the alias and the duplicate `helper_method` arg are harmless no-ops.
645
+
646
+ - [ ] **Step 4: Run tests to verify they pass**
647
+
648
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/auth/rodauth_test.rb`
649
+ Expected: PASS.
650
+
651
+ - [ ] **Step 5: Commit** *(only if authorized)*
652
+
653
+ ```bash
654
+ git add lib/plutonium/auth/rodauth.rb test/plutonium/auth/rodauth_test.rb
655
+ git commit -m "feat(auth): expose named current_<account> accessor on Rodauth(name)"
656
+ ```
657
+
658
+ ---
659
+
660
+ ## Task 6: Documentation + config comments
661
+
662
+ **Goal:** Document the `:plain` shell, the controller `rail` DSL, and the named Rodauth accessor; add explanatory comments where `shell` is configured.
663
+
664
+ **Files:**
665
+ - Modify: `lib/plutonium/configuration.rb` (the `shell` attr comment, ~line 30)
666
+ - Modify: `lib/generators/pu/core/install/templates/config/initializers/plutonium.rb` (~line 6)
667
+ - Modify: relevant docs in `docs/` and skills in `.claude/skills/` (UI/app shell docs; auth docs)
668
+
669
+ **Acceptance Criteria:**
670
+ - [ ] `configuration.rb` documents `:plain` alongside `:modern`/`:classic`.
671
+ - [ ] Install initializer notes the `:plain` option in a comment.
672
+ - [ ] Docs/skills explain: `config.shell = :plain`, per-controller `rail false`/`true` (incl. the one-line portal-concern example), and the named `current_<account>` accessor.
673
+ - [ ] `grep -ri "rail false" docs .claude/skills` returns at least one hit.
674
+
675
+ **Verify:** `yarn docs:build` → no broken links; `grep -rl "config.shell = :plain" docs .claude/skills` → ≥ 1.
676
+
677
+ **Steps:**
678
+
679
+ - [ ] **Step 1: Update the configuration comment**
680
+
681
+ In `lib/plutonium/configuration.rb`, change the `shell` doc comment (line 30) from:
682
+
683
+ ```ruby
684
+ # @return [Symbol] :classic (legacy Header/Sidebar) or :modern (Topbar/IconRail)
685
+ attr_accessor :shell
686
+ ```
687
+ to:
688
+ ```ruby
689
+ # @return [Symbol] :modern (Topbar/IconRail, default), :plain (Topbar, no
690
+ # icon rail), or :classic (legacy Header/Sidebar).
691
+ attr_accessor :shell
692
+ ```
693
+
694
+ - [ ] **Step 2: Update the install initializer comment**
695
+
696
+ In `lib/generators/pu/core/install/templates/config/initializers/plutonium.rb`, add a comment above `config.shell = :modern`:
697
+
698
+ ```ruby
699
+ # Shell variant: :modern (icon rail), :plain (no rail), or :classic (legacy).
700
+ config.shell = :modern
701
+ ```
702
+
703
+ - [ ] **Step 3: Update docs and skills**
704
+
705
+ Add a "Shell variants & the icon rail" subsection to the appropriate UI/app shell doc under `docs/` (follow the existing doc structure) covering:
706
+ - `config.shell = :plain` for an app-wide rail-less shell.
707
+ - Per-controller / per-portal override:
708
+ ```ruby
709
+ module CustomerPortal
710
+ module Concerns
711
+ module Controller
712
+ extend ActiveSupport::Concern
713
+ included { rail false } # entire portal rail-less
714
+ end
715
+ end
716
+ end
717
+ ```
718
+ - Stable hooks `pu-topbar` / `pu-sticky-footer` and the `html.pu-no-rail` body class for custom CSS.
719
+
720
+ Update the `plutonium-ui` (and/or `plutonium-app`) skill in `.claude/skills/` with the same `rail`/`:plain` guidance, and the `plutonium-auth` skill with the named `current_<account>` accessor.
721
+
722
+ - [ ] **Step 4: Verify docs build and grep**
723
+
724
+ Run: `yarn docs:build`
725
+ Expected: builds with no broken-link errors.
726
+ Run: `grep -rl "config.shell = :plain" docs .claude/skills`
727
+ Expected: ≥ 1 file.
728
+
729
+ - [ ] **Step 5: Commit** *(only if authorized)*
730
+
731
+ ```bash
732
+ git add lib/plutonium/configuration.rb lib/generators/pu/core/install/templates/config/initializers/plutonium.rb docs .claude/skills
733
+ git commit -m "docs(layout): document :plain shell, rail DSL, and named auth accessor"
734
+ ```
735
+
736
+ ---
737
+
738
+ ## Final verification
739
+
740
+ - [ ] Run the full affected suite on the default Rails version:
741
+
742
+ ```bash
743
+ bundle exec appraisal rails-8.1 ruby -Itest \
744
+ test/plutonium/core/controller_rail_test.rb \
745
+ test/plutonium/ui/layout/resource_layout_rail_test.rb \
746
+ test/plutonium/ui/layout/topbar_test.rb \
747
+ test/plutonium/ui/form/components/sticky_footer_test.rb \
748
+ test/integration/plain_shell_rendering_test.rb \
749
+ test/plutonium/auth/rodauth_test.rb
750
+ ```
751
+ Expected: all green.
752
+
753
+ - [ ] Run the whole suite across versions to catch regressions:
754
+
755
+ ```bash
756
+ bundle exec appraisal rake test
757
+ ```
758
+ Expected: no new failures versus baseline.
759
+
760
+ - [ ] Confirm `app/assets/plutonium.css` was rebuilt (`yarn build`) and contains `pu-no-rail`.
761
+ ```