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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-auth/SKILL.md +1 -1
- data/.claude/skills/plutonium-ui/SKILL.md +20 -0
- data/CHANGELOG.md +14 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/reference/configuration.md +1 -1
- data/docs/reference/resource/definition.md +2 -2
- data/docs/reference/ui/layouts.md +61 -1
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +11 -2
- data/docs/superpowers/plans/2026-06-14-railless-portal.md +761 -0
- data/docs/superpowers/plans/2026-06-14-railless-portal.md.tasks.json +51 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +15 -5
- data/docs/superpowers/specs/2026-06-14-railless-portal-design.md +275 -0
- data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/plutonium/auth/rodauth.rb +2 -1
- data/lib/plutonium/configuration.rb +2 -1
- data/lib/plutonium/core/controller.rb +38 -0
- data/lib/plutonium/definition/form_layout.rb +5 -6
- data/lib/plutonium/package/engine.rb +10 -0
- data/lib/plutonium/ui/form/components/sticky_footer.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +10 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +28 -6
- data/lib/plutonium/ui/layout/topbar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +9 -0
- metadata +5 -2
|
@@ -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
|
-
- **
|
|
150
|
-
|
|
151
|
-
|
|
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:**
|
|
179
|
-
with defaults (is **not** hidden)
|
|
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.
|
|
@@ -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] :
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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: {
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -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;
|