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
@@ -80,6 +80,13 @@ rails g pu:rodauth:admin admin --extra-attributes=name:string,department:string
80
80
  enum :role, super_admin: 0, admin: 1
81
81
  ```
82
82
 
83
+ **Invite + resend.** The admin resource gets two actions:
84
+
85
+ - **Invite** — invite a new admin by email; Rodauth sends a verification link and the invitee sets their own password through the verify flow.
86
+ - **Resend invitation** — re-send the verification email. Only shown for admins who haven't verified yet.
87
+
88
+ This uses Rodauth account verification, separate from the [Tenancy › Invites](/reference/tenancy/invites) system.
89
+
83
90
  Rake task for direct admin creation (generated alongside the account — namespace is `rodauth`, task name is the account name):
84
91
 
85
92
  ```bash
@@ -35,7 +35,7 @@ Loads the baseline defaults for a given framework version. Call this first; late
35
35
  | `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. You rarely set this in an app — see [Development mode](#development-mode). |
36
36
  | `cache_discovery` | `true` outside `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
37
37
  | `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
38
- | `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail) or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
38
+ | `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail), `:plain` (topbar, no icon rail), or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
39
39
  | `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
40
40
  | `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
41
41
  | `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
@@ -459,6 +459,135 @@ validate do
459
459
  end
460
460
  ```
461
461
 
462
+ ## Form layout
463
+
464
+ Declare a declarative layout for forms without changing per-field configuration. Sections are evaluated against the policy-filtered field list at render time, so a field filtered out by the policy is simply skipped.
465
+
466
+ ```ruby
467
+ class PostDefinition < ResourceDefinition
468
+ form_layout do
469
+ section :identity, :title, :slug,
470
+ label: "Post identity", description: "Visible URL and title"
471
+
472
+ section :content, :body, :excerpt,
473
+ collapsible: true, columns: 1
474
+
475
+ section :publishing, :published_at, :category,
476
+ collapsible: true, collapsed: true,
477
+ condition: -> { current_user.publisher? }
478
+
479
+ ungrouped label: "Other details"
480
+ end
481
+ end
482
+ ```
483
+
484
+ ### `form_layout` block
485
+
486
+ The block is evaluated once and stored on the class. Re-declaring `form_layout` in a subclass replaces the parent layout as a unit; per-field `input` config inherits normally.
487
+
488
+ With no `form_layout` declared the form renders unchanged as a single responsive grid — fully backwards-compatible.
489
+
490
+ ### `section(key, *fields, **opts)`
491
+
492
+ Groups a set of fields under an optional heading.
493
+
494
+ | Argument | Description |
495
+ |---|---|
496
+ | `key` | Symbol. `:ungrouped` is reserved — use the `ungrouped` macro instead (raises `ArgumentError` otherwise). |
497
+ | `*fields` | Ordered field keys to place in this section. |
498
+ | `label:` | Section heading. Defaults to `key.to_s.humanize` (e.g. `:shipping_address` → `"Shipping address"`). |
499
+ | `description:` | Optional help line rendered below the heading. |
500
+ | `collapsible:` | Boolean (default `false`). Wraps the section in a native `<details>/<summary>` (no JS). |
501
+ | `collapsed:` | Boolean (default `false`). Initial collapsed state when `collapsible: true`. |
502
+ | `columns:` | Positive Integer. Overrides the section grid column count (e.g. `columns: 2`). Omit to use the form's default responsive grid. Must be a positive Integer — any other value raises. (Literal only — not dynamic.) |
503
+ | `condition:` | Lambda evaluated in the form instance context — same semantics as `input ..., condition:`. `object`, `current_user`, helpers etc. are all available. A falsey result hides the entire section and withholds its fields (they do not spill into `ungrouped`). |
504
+
505
+ Every option except `columns:` may be either a literal **or a proc** resolved at render time in the same form instance context as `condition:` (so `object`, `current_user`, `params`, helpers are all available). This makes the layout record-aware — e.g. collapse a section by default only for existing records:
506
+
507
+ ```ruby
508
+ section :advanced, :seo_title, :notes,
509
+ collapsible: true,
510
+ collapsed: -> { object.persisted? }, # open for new, collapsed for edits
511
+ label: -> { object.new_record? ? "Set up" : "Advanced" }
512
+ ```
513
+
514
+ Empty sections (all fields filtered by the policy, or none assigned) are **not** hidden automatically. Use `condition:` to hide a section conditionally.
515
+
516
+ ### `ungrouped(**opts)`
517
+
518
+ A macro (not a `section` call) that configures the implicit bucket collecting every permitted field not claimed by any `section`. Takes **no field list** — its fields are computed at render time.
519
+
520
+ - Accepts the same options as `section`: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:`, `condition:`.
521
+ - **Position** — where you call `ungrouped` in the block is where leftovers appear. Omit it entirely and leftovers render **last**, after every declared section, with no heading. (Declaring `ungrouped` at the very end is therefore equivalent to omitting it, except that the explicit form lets you add a `label:` and other options.)
522
+ - Declaring `ungrouped` more than once in a single `form_layout` raises `ArgumentError`.
523
+
524
+ ```ruby
525
+ form_layout do
526
+ section :advanced, :seo_title, :seo_description, collapsible: true
527
+ ungrouped label: "Core fields" # leftovers rendered here, with a heading
528
+ end
529
+
530
+ # To float leftovers ABOVE your sections, declare `ungrouped` first:
531
+ form_layout do
532
+ ungrouped label: "Core fields"
533
+ section :advanced, :seo_title, :seo_description, collapsible: true
534
+ end
535
+ ```
536
+
537
+ ### Layout references keys; config stays on `input`
538
+
539
+ `form_layout` and `section` carry section-level options only. All per-field rendering config — `as:`, the field's own `label:`, `choices:`, per-field `condition:`, `pre_submit:`, blocks — remains on the `input` declaration. Layout never duplicates field config.
540
+
541
+ This includes a field's **column span**. In a section with `columns:`, fields flow into single grid cells by default; a field that declares its own span via `wrapper: {class: "col-span-..."}` keeps it — a field-level span always wins, so you can opt one field back to full width inside a multi-column section:
542
+
543
+ ```ruby
544
+ input :notes, wrapper: {class: "col-span-full"} # spans the whole row...
545
+
546
+ form_layout do
547
+ section :details, :first_name, :last_name, :notes, columns: 2 # ...even here
548
+ end
549
+ ```
550
+
551
+ ```ruby
552
+ class ArticleDefinition < ResourceDefinition
553
+ # per-field config on input — untouched by form_layout
554
+ input :body, as: :markdown
555
+ input :published_at, hint: "Leave blank to save as draft"
556
+ input :visibility, as: :select, choices: %w[public private unlisted]
557
+
558
+ form_layout do
559
+ section :writing, :title, :body, :excerpt, label: "Content"
560
+ section :meta, :published_at, :visibility, :tags, label: "Publishing settings"
561
+ end
562
+ end
563
+ ```
564
+
565
+ ### Fields not in the permitted set are skipped
566
+
567
+ A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there — a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting — is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out.
568
+
569
+ ### On interactions
570
+
571
+ `form_layout` is also available on `Plutonium::Interaction::Base`. The same DSL groups the interaction's `attribute` declarations into sections. Interaction forms (`Plutonium::UI::Form::Interaction`) pick up the layout automatically — no extra wiring needed.
572
+
573
+ Dynamic options and `condition:` work here too, with one difference: in an interaction form `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource` — so e.g. `collapsed: -> { object.resource.archived? }`.
574
+
575
+ ```ruby
576
+ class PublishPostInteraction < Plutonium::Interaction::Base
577
+ attribute :publish_at, :datetime
578
+ attribute :notify_subscribers, :boolean, default: false
579
+ attribute :notify_message, :string
580
+
581
+ form_layout do
582
+ section :timing, :publish_at, label: "When to publish"
583
+ section :notifications, :notify_subscribers, :notify_message,
584
+ label: "Subscriber notifications",
585
+ collapsible: true,
586
+ condition: -> { object.has_subscribers? }
587
+ end
588
+ end
589
+ ```
590
+
462
591
  ## File uploads
463
592
 
464
593
  ```ruby
@@ -47,40 +47,70 @@ end
47
47
 
48
48
  ## Custom layouts
49
49
 
50
- ### Sectioned form
50
+ ### Sectioned form (declarative — preferred)
51
+
52
+ Declare sections in the **definition** using `form_layout`. The form picks up the layout automatically — no `Form` subclass needed for common cases.
51
53
 
52
54
  ```ruby
53
- class Form < Form
54
- def form_template
55
- section("Basic Information") do
56
- render_resource_field :title
57
- render_resource_field :slug
58
- end
55
+ class PostDefinition < ResourceDefinition
56
+ form_layout do
57
+ section :basics, :title, :slug,
58
+ label: "Basic information"
59
59
 
60
- section("Content") do
61
- render_resource_field :content
62
- render_resource_field :excerpt
63
- end
60
+ section :content, :body, :excerpt,
61
+ label: "Content", columns: 1
62
+
63
+ section :publishing, :published_at, :category,
64
+ label: "Publishing", collapsible: true, collapsed: true
65
+ end
66
+ end
67
+ ```
68
+
69
+ This handles headings, collapsible panels, per-section column counts, and `condition:`-based visibility — all with no view code. See [Resource › Definition › Form layout](/reference/resource/definition#form-layout) for the full DSL reference, including `ungrouped`, `condition:`, `columns:`, and the "On interactions" note.
64
70
 
65
- section("Publishing") do
66
- render_resource_field :published_at
67
- render_resource_field :category
71
+ ### Full control: override `render_fields`
72
+
73
+ When the declarative DSL doesn't cover your use case — asymmetric multi-column layouts, embedding a panel widget between sections, etc. — override `render_fields` in a nested `Form` class:
74
+
75
+ ```ruby
76
+ class PostDefinition < ResourceDefinition
77
+ class Form < Form
78
+ def form_template
79
+ render_fields # replaced below
80
+ render_actions
68
81
  end
69
82
 
70
- render_actions
71
- end
83
+ def render_fields
84
+ div(class: "mb-8") do
85
+ h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Basic Information" }
86
+ fields_wrapper do
87
+ render_resource_field :title
88
+ render_resource_field :slug
89
+ end
90
+ end
72
91
 
73
- private
92
+ div(class: "mb-8") do
93
+ h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Content" }
94
+ fields_wrapper do
95
+ render_resource_field :content
96
+ render_resource_field :excerpt
97
+ end
98
+ end
74
99
 
75
- def section(title, &)
76
- div(class: "mb-8") do
77
- h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { title }
78
- fields_wrapper(&)
100
+ div(class: "mb-8") do
101
+ h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Publishing" }
102
+ fields_wrapper do
103
+ render_resource_field :published_at
104
+ render_resource_field :category
105
+ end
106
+ end
79
107
  end
80
108
  end
81
109
  end
82
110
  ```
83
111
 
112
+ Prefer `form_layout` in the definition — it keeps layout config out of view code and works for interactions too.
113
+
84
114
  ### Two-column layout
85
115
 
86
116
  ```ruby
@@ -1,12 +1,13 @@
1
1
  # Layouts
2
2
 
3
- The overall page chrome — topbar, sidebar, footer, body wrapping. Plutonium ships two shells; you can eject the templates or write a custom `ResourceLayout` for total control.
3
+ The overall page chrome — topbar, sidebar, footer, body wrapping. Plutonium ships three shells; you can eject the templates or write a custom `ResourceLayout` for total control.
4
4
 
5
5
  ## Shell
6
6
 
7
7
  ```ruby
8
8
  Plutonium.configure do |config|
9
9
  config.shell = :modern # default — topbar + icon rail
10
+ # config.shell = :plain # topbar, no icon rail (rail-less app)
10
11
  # config.shell = :classic # legacy header + sidebar (only when upgrading)
11
12
  end
12
13
  ```
@@ -15,6 +16,41 @@ end
15
16
  If you're starting fresh, use `:modern`. `:classic` exists so apps upgrading from pre-`:modern` versions can preserve their chrome while migrating.
16
17
  :::
17
18
 
19
+ ## Shell variants & the icon rail
20
+
21
+ `config.shell` selects the chrome for the whole app:
22
+
23
+ - `:modern` (default) — Topbar plus the desktop icon rail.
24
+ - `:plain` — Topbar but **no** icon rail. The Topbar is kept; only the rail is removed, so the whole app is rail-less.
25
+ - `:classic` — legacy Header/Sidebar (upgrade paths only).
26
+
27
+ ### Per-controller / per-portal override
28
+
29
+ Any Plutonium resource controller exposes a class-level `rail` DSL that overrides the shell default. It's a `class_attribute`, so it's inherited: a portal opts its entire surface in or out by calling `rail false` (or `rail true`) once in its controller concern.
30
+
31
+ ```ruby
32
+ module CustomerPortal
33
+ module Concerns
34
+ module Controller
35
+ extend ActiveSupport::Concern
36
+ included { rail false } # entire portal rail-less
37
+ end
38
+ end
39
+ end
40
+ ```
41
+
42
+ `rail nil` (the default) inherits the shell default — the rail shows when `config.shell == :modern`. Read the resolved value with the `rail?` predicate.
43
+
44
+ ### Stable CSS hooks
45
+
46
+ Rail-less rendering exposes a few stable hooks for custom overrides:
47
+
48
+ - `pu-topbar` — class on the Topbar nav.
49
+ - `pu-sticky-footer` — class on the form sticky-footer div.
50
+ - `html.pu-no-rail` — root class present whenever the current page is rail-less.
51
+
52
+ A built-in rule cancels the desktop rail inset on `.pu-topbar` and `.pu-sticky-footer` under `html.pu-no-rail`; target these hooks to layer your own CSS.
53
+
18
54
  ## Eject the chrome for per-portal customization
19
55
 
20
56
  ```bash