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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-auth/SKILL.md +8 -2
- data/.claude/skills/plutonium-ui/SKILL.md +12 -0
- data/CHANGELOG.md +15 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/configuration.md +1 -1
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/ui/forms.md +51 -21
- data/docs/reference/ui/layouts.md +37 -1
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +926 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
- 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 +247 -0
- data/docs/superpowers/specs/2026-06-14-railless-portal-design.md +275 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
- data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
- data/lib/generators/pu/rodauth/views_generator.rb +1 -1
- data/lib/plutonium/auth/rodauth.rb +2 -1
- data/lib/plutonium/configuration.rb +2 -1
- data/lib/plutonium/core/controller.rb +19 -0
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/form_layout.rb +143 -0
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +17 -7
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/components/sticky_footer.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/ui/layout/base.rb +5 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +22 -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
- data/src/css/slim_select.css +11 -2
- 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.
|
|
@@ -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.
|
|
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
|