plutonium 0.59.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-auth/SKILL.md +8 -2
  3. data/.claude/skills/plutonium-ui/SKILL.md +12 -0
  4. data/CHANGELOG.md +15 -0
  5. data/app/assets/plutonium.css +1 -1
  6. data/docs/reference/auth/accounts.md +7 -0
  7. data/docs/reference/configuration.md +1 -1
  8. data/docs/reference/resource/definition.md +129 -0
  9. data/docs/reference/ui/forms.md +51 -21
  10. data/docs/reference/ui/layouts.md +37 -1
  11. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +926 -0
  12. data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
  13. data/docs/superpowers/plans/2026-06-14-railless-portal.md +761 -0
  14. data/docs/superpowers/plans/2026-06-14-railless-portal.md.tasks.json +51 -0
  15. data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +247 -0
  16. data/docs/superpowers/specs/2026-06-14-railless-portal-design.md +275 -0
  17. data/gemfiles/rails_7.gemfile.lock +1 -1
  18. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  19. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  20. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  21. data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
  22. data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
  23. data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
  24. data/lib/generators/pu/rodauth/views_generator.rb +1 -1
  25. data/lib/plutonium/auth/rodauth.rb +2 -1
  26. data/lib/plutonium/configuration.rb +2 -1
  27. data/lib/plutonium/core/controller.rb +19 -0
  28. data/lib/plutonium/definition/base.rb +1 -0
  29. data/lib/plutonium/definition/form_layout.rb +143 -0
  30. data/lib/plutonium/interaction/base.rb +1 -0
  31. data/lib/plutonium/package/engine.rb +17 -7
  32. data/lib/plutonium/ui/form/components/section.rb +58 -0
  33. data/lib/plutonium/ui/form/components/sticky_footer.rb +1 -1
  34. data/lib/plutonium/ui/form/resource.rb +85 -7
  35. data/lib/plutonium/ui/layout/base.rb +5 -0
  36. data/lib/plutonium/ui/layout/resource_layout.rb +22 -6
  37. data/lib/plutonium/ui/layout/topbar.rb +1 -1
  38. data/lib/plutonium/version.rb +1 -1
  39. data/package.json +1 -1
  40. data/src/css/components.css +9 -0
  41. data/src/css/slim_select.css +11 -2
  42. metadata +11 -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
+ }
@@ -0,0 +1,247 @@
1
+ # Form Sectioning — Design
2
+
3
+ **Date:** 2026-06-14
4
+ **Status:** Approved (pending spec review)
5
+
6
+ ## Problem
7
+
8
+ Plutonium forms render every field in a single responsive grid
9
+ (`Form::Resource#render_fields` walks a flat `resource_fields` list inside one
10
+ `fields_wrapper`). There is no way to group fields under headings ("Personal
11
+ details", "Address", …). We want a clean DSL for *sectioning* forms that works
12
+ for both resource definitions and interactions, without disturbing per-field
13
+ configuration.
14
+
15
+ ## Constraints discovered in the codebase
16
+
17
+ - **Field config lives in the definition** (`input :x, ...` via
18
+ `DefineableProps`, stored as ordered hashes), but the **set and order of
19
+ fields actually rendered come from the policy** (`permitted_attributes_for(action)`,
20
+ via `submittable_attributes_for`). The form receives that flat list as
21
+ `resource_fields`.
22
+ - **Interactions reuse the resource form.** `Form::Interaction < Form::Resource`
23
+ and only swaps in `resource_fields` (from `interaction.attribute_names`) and
24
+ `resource_definition` (the interaction instance). So sectioning added to
25
+ `Form::Resource#render_fields` is inherited by interactions for free.
26
+ - The shared definition DSL is composed from `Plutonium::Definition::*` concern
27
+ modules. `StructuredInputs` is the precedent for a module mixed into **both**
28
+ `Definition::Base` and `Interaction::Base`.
29
+
30
+ Therefore: sectioning is a **presentation concern declared in the definition**
31
+ that must group a **policy-driven** field list — skipping fields the policy
32
+ filtered out and hiding sections that end up empty.
33
+
34
+ ## DSL
35
+
36
+ A new shared module `Plutonium::Definition::FormLayout` provides `form_layout`.
37
+
38
+ ```ruby
39
+ form_layout do
40
+ section :identity, :name, :date_of_birth, :grade,
41
+ label: "Your identification", description: "Basic info"
42
+
43
+ section :address, :street, :city, :country,
44
+ collapsible: true, columns: 2,
45
+ condition: -> { object.requires_address? }
46
+
47
+ ungrouped label: "Other", collapsible: true
48
+ end
49
+ ```
50
+
51
+ ### `section(key, *fields, **opts)`
52
+
53
+ - `key` — Symbol, the section's identity. Heading defaults to `key.to_s.humanize`.
54
+ `:ungrouped` is **reserved** for the macro below — `section :ungrouped, …`
55
+ raises `ArgumentError`.
56
+ - `*fields` — ordered field keys placed in this section.
57
+ - `**opts`:
58
+ - `label:` — overrides the humanized heading.
59
+ - `description:` — optional help line under the heading.
60
+ - `collapsible:` — Boolean (default `false`). Renders as a native
61
+ `<details>/<summary>` disclosure (no JS).
62
+ - `collapsed:` — Boolean (default `false`). Initial state when collapsible;
63
+ `false` ⇒ the `<details>` is `open`.
64
+ - `columns:` — Integer overriding the section's grid column count. Default
65
+ inherits the form's responsive grid.
66
+ - `condition:` — lambda evaluated in the **form instance context** (same
67
+ semantics as `input ..., condition:`; `object` and helpers available). A
68
+ falsey result renders nothing for the section.
69
+
70
+ ### `ungrouped(**opts)`
71
+
72
+ - Configures the implicit bucket that auto-collects every permitted field not
73
+ claimed by a `section`. Takes **no field list**.
74
+ - Accepts the same options as `section` (`label:`, `description:`,
75
+ `collapsible:`, `collapsed:`, `columns:`, `condition:`).
76
+ - **Position:** where the macro is called sets where leftovers render. If the
77
+ macro is omitted, leftovers render **last** (appended after all declared
78
+ sections), with **no heading**. _(Amended — was "first"; see Amendments.)_
79
+ - Calling `ungrouped` more than once in a single `form_layout` raises
80
+ `ArgumentError`.
81
+
82
+ ### Field configuration stays on `input`
83
+
84
+ `form_layout`/`section` only reference field **keys** and carry **section-level**
85
+ options. All per-field rendering config (`as:`, the field's own `label:`,
86
+ `choices:`, per-field `condition:`, `pre_submit:`, blocks) remains on the `input`
87
+ declaration. Layout never duplicates field config.
88
+
89
+ ### Inheritance / override
90
+
91
+ - Re-declaring `form_layout` in a subclass **replaces** the parent layout as a
92
+ unit. Field-level `input` config continues to inherit normally.
93
+ - The section registry is duplicated to subclasses on `inherited` (mirrors
94
+ `StructuredInputs`).
95
+
96
+ ### Backwards compatibility
97
+
98
+ No `form_layout` declared ⇒ the current single-grid behavior is used unchanged.
99
+ This is equivalent to one implicit, heading-less `ungrouped` region.
100
+
101
+ ## Rendering
102
+
103
+ `Plutonium::Definition::FormLayout` exposes an ordered, frozen registry of
104
+ section specs (and the `ungrouped` spec + its position) on definition and
105
+ interaction instances.
106
+
107
+ `Form::Resource#render_fields` becomes:
108
+
109
+ 1. **No layout** → existing path: one `fields_wrapper` over all `resource_fields`.
110
+ 2. **Layout present**:
111
+ - Assign each `section` its `fields ∩ resource_fields`, preserving the
112
+ section's declared field order.
113
+ - The `ungrouped` bucket gets `resource_fields` minus all claimed fields,
114
+ preserving `resource_fields` order.
115
+ - Build the ordered render list: sections in declared order with the
116
+ `ungrouped` bucket inserted at its declared position (default: **last**).
117
+ - For each entry: evaluate `condition` (skip if falsey); render a Section
118
+ component with its renderable fields. Empty sections are **not**
119
+ special-cased — they render with defaults (see Edge cases).
120
+
121
+ Field rendering itself still goes through the existing `render_resource_field`,
122
+ so all input types/components are unaffected.
123
+
124
+ ### Section component
125
+
126
+ New `Plutonium::UI::Form::Components::Section` (Phlex). Responsibilities:
127
+
128
+ - Wrapper + heading (`label`) + optional `description`.
129
+ - When `collapsible`, wrap in native `<details>`/`<summary>` (`open` unless
130
+ `collapsed`), styled with Tailwind/`--pu-*` tokens.
131
+ - A grid (`fields_wrapper`-style) whose column count comes from `columns:` when
132
+ given, else the existing responsive default
133
+ (`grid-cols-1 md:grid-cols-2 2xl:grid-cols-4`).
134
+ - Yields to render the section's fields via `render_resource_field`.
135
+
136
+ A small helper maps `columns:` → grid classes (e.g. `1 → "grid grid-cols-1 gap-6"`,
137
+ `2 → "grid grid-cols-1 md:grid-cols-2 gap-6"`). Theme entries added for section
138
+ heading/description/wrapper so they're themeable like the rest of the form.
139
+
140
+ ## Edge cases
141
+
142
+ - **Field permitted but in no section** → falls into `ungrouped`.
143
+ - **Section references a policy-filtered field** → that individual field is
144
+ skipped (no input is rendered for an unpermitted attribute).
145
+ - **Empty section** (all fields filtered out, or none assigned) → **not
146
+ hidden**. It renders through the normal path with defaults (its default/declared
147
+ chrome). There is no automatic empty-hiding; to hide a section conditionally,
148
+ use `condition:`.
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
+ - **`condition` falsey** → section renders nothing; its fields do **not** spill
153
+ into `ungrouped` (they remain owned by the suppressed section).
154
+ - **No leftovers** → `ungrouped` renders with defaults (with no fields and no
155
+ configured heading, that is simply nothing visible; a configured `label:`
156
+ still renders).
157
+
158
+ ## Files
159
+
160
+ - **New** `lib/plutonium/definition/form_layout.rb` — DSL module: `form_layout`
161
+ block builder, ordered + inheritable section registry, `section` / `ungrouped`,
162
+ instance readers, validation (duplicate `ungrouped`, etc.).
163
+ - **Modify** `lib/plutonium/definition/base.rb` — `include FormLayout`.
164
+ - **Modify** `lib/plutonium/interaction/base.rb` — `include FormLayout`.
165
+ - **New** `lib/plutonium/ui/form/components/section.rb` — section component.
166
+ - **Modify** `lib/plutonium/ui/form/resource.rb` — `render_fields` grouping; columns→grid helper.
167
+ - **Modify** `lib/plutonium/ui/form/theme.rb` — section heading/description/wrapper tokens.
168
+
169
+ ## Testing (RSpec)
170
+
171
+ - **DSL/registry:** sections recorded in order with options; `ungrouped` spec +
172
+ position; humanized default label; `label:` override; duplicate `ungrouped`
173
+ raises; `section :ungrouped` raises; inheritance duplicates registry;
174
+ re-declaring `form_layout` replaces.
175
+ - **Assignment:** fields land in the right section in declared order; leftovers
176
+ collect into `ungrouped`; `ungrouped` default position is last; explicit
177
+ position honored.
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
+ - **Conditions:** falsey `condition` hides the section and withholds its fields.
181
+ - **Rendering:** headings/descriptions present; collapsible emits
182
+ `<details>`/`<summary>` with correct `open`; `columns:` changes grid classes.
183
+ - **Interactions:** an interaction with `form_layout` sections renders grouped
184
+ via `Form::Interaction`.
185
+ - **Backwards-compat:** a definition with no `form_layout` renders the single
186
+ grid exactly as before.
187
+
188
+ ## Out of scope (YAGNI)
189
+
190
+ - Applying sections to show/display pages or the index grid (forms only for now;
191
+ the same registry could later be reused by `Display::Resource`).
192
+ - Nested sections / tabs.
193
+ - Per-field layout hints (e.g. `field :notes, span: 2`) — Style 1 keeps fields
194
+ positional; this can be added later via the nested-block form if needed.
195
+ - Stimulus-driven animated collapse (native `<details>` chosen for leanness).
196
+
197
+ ## Amendments (post-implementation)
198
+
199
+ Changes made after the original plan landed:
200
+
201
+ - **Implicit `ungrouped` placement: first → last.** When no `ungrouped` macro is
202
+ declared, leftover fields are now appended **after** all declared sections
203
+ (was: prepended). This matches the convention of the explicit macro ("the
204
+ rest" trails the sections you care about) and makes "omit it" equivalent to
205
+ "declare it last." To float leftovers above your sections, declare `ungrouped`
206
+ explicitly at the top.
207
+
208
+ - **`columns:` actually lays out in a grid.** Previously every field wrapper got
209
+ `col-span-full`, so a section's `columns: N` had no visible effect. Fields in a
210
+ multi-column section now flow into single grid cells. A field that declares its
211
+ own span (`input :x, wrapper: {class: "col-span-..."}`) **always wins** — in any
212
+ section — so authors can opt a field back to full width (or wider) inside a
213
+ multi-column section.
214
+
215
+ - **Dynamic section options.** Every section option except `columns:`
216
+ (`collapsed`, `collapsible`, `label`, `description`, plus the existing
217
+ `condition`) may be a **proc**, resolved at render time in the form instance
218
+ context — the same context as input/section `condition:` (so `object`,
219
+ `current_user`, `params`, helpers are available). The whole layout is resolved
220
+ once per render in `Form::Resource#resolve_form_layout` (visibility + option
221
+ evaluation in one pass); `render_form_section` is pure presentation. `columns:`
222
+ stays a validated literal (it feeds the grid class).
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
+
234
+ - **Interactions: verified + exercised.** `Form::Interaction < Form::Resource`
235
+ already inherited the layout path; this is now covered by a dummy interaction
236
+ (`ReconfigureKitchenSink`, a record action on `KitchenSink`) and an integration
237
+ test. In an interaction form `object` is the interaction instance and
238
+ `object.resource` is the record, so record-aware dynamic options work there too
239
+ (e.g. `collapsed: -> { object.resource.archived? }`).
240
+
241
+ - **Unrelated fix surfaced while driving the dummy in `development`:** the package
242
+ engine system (`Plutonium::Package::Engine`) called `Rails.application.initializers`
243
+ from a `before_configuration` hook, prematurely memoizing `Rails.application.railties`
244
+ and dropping package engines from the autoload paths when a second
245
+ `Rails::Application` (combustion, in dev) was instantiated before the packages
246
+ glob ran — surfacing as `uninitialized constant Blogging::Post`. The view-path
247
+ neutralization was moved to a real initializer (`before: :add_view_paths`).
@@ -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.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.58.1)
4
+ plutonium (0.59.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.58.1)
4
+ plutonium (0.59.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.58.1)
4
+ plutonium (0.59.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -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
@@ -95,13 +95,16 @@ module Pu
95
95
  def create_invite_interaction
96
96
  template "app/interactions/invite_admin_interaction.rb",
97
97
  "app/interactions/#{normalized_name}/invite_interaction.rb"
98
+ template "app/interactions/resend_admin_interaction.rb",
99
+ "app/interactions/#{normalized_name}/resend_invite_interaction.rb"
98
100
 
99
101
  inject_into_file "app/definitions/#{normalized_name}_definition.rb",
100
- " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n",
102
+ " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n" \
103
+ " action :resend_invite, interaction: #{name.classify}::ResendInviteInteraction, record_action: true, category: :secondary\n",
101
104
  after: /class #{name.classify}Definition < .+\n/
102
105
 
103
106
  inject_into_file "app/policies/#{normalized_name}_policy.rb",
104
- "def invite?\n true\n end\n\n ",
107
+ "def invite?\n true\n end\n\n def resend_invite?\n record.unverified?\n end\n\n ",
105
108
  before: "# Core attributes"
106
109
  end
107
110
 
@@ -20,7 +20,7 @@ module Pu
20
20
  desc "Generate migrations for supported features\n\n" \
21
21
  "Supported Features\n" \
22
22
  "=========================================\n" \
23
- "#{MIGRATION_CONFIG.keys.sort.map(&:to_s).join "\n"}\n\n\n\n"
23
+ "#{MIGRATION_CONFIG.keys.sort.join "\n"}\n\n\n\n"
24
24
 
25
25
  class_option :features, required: true, type: :array,
26
26
  desc: "Rodauth features to create tables for (otp, sms_codes, single_session, account_expiration etc.)"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= name.classify %>::ResendInviteInteraction < Plutonium::Resource::Interaction
4
+ presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
5
+
6
+ attribute :resource
7
+
8
+ def execute
9
+ unless resource.unverified?
10
+ return failed("Can only resend invitations to unverified accounts")
11
+ end
12
+
13
+ RodauthApp.rodauth(:<%= normalized_name %>).verify_account_resend(login: resource.email)
14
+ succeed(resource).with_message("Invitation resent to #{resource.email}")
15
+ rescue ::Rodauth::InternalRequestError => e
16
+ failed(e.message)
17
+ end
18
+ end