plutonium 0.60.5 → 0.61.0

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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -0,0 +1,447 @@
1
+ # Wizards
2
+
3
+ ::: warning Experimental
4
+ Wizards are experimental — the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ Build multi-step flows — onboarding, checkout, "create several related records across screens", branching questionnaires — as a single declarative Ruby class.
8
+
9
+ A wizard collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses Plutonium's existing field DSL (`attribute`/`input`/`validates`/`structured_input`/`form_layout`), form rendering, actions, and policies — it does **not** invent a parallel stack.
10
+
11
+ ## Goal
12
+
13
+ The user lands on the first step, fills it in, clicks Next, and walks through the flow. Branching steps appear or disappear based on earlier answers. A built-in review step recaps everything and gates a Finish button. On finish, `execute` writes the records — atomically by default.
14
+
15
+ ## Prerequisites — enable the subsystem
16
+
17
+ Wizards are core code, but the storage table is **opt-in** so apps that don't use wizards stay schema-clean. Enable it in your Plutonium initializer:
18
+
19
+ ```ruby
20
+ # config/initializers/plutonium.rb
21
+ Plutonium.configure do |config|
22
+ config.wizards.enabled = true # false by default; registers the gem migration
23
+ config.wizards.cleanup_after = 14.days # global default idle TTL for the sweep
24
+ end
25
+ ```
26
+
27
+ Then run the migration (it ships in the gem and runs in place — no copy step):
28
+
29
+ ```bash
30
+ rails db:migrate
31
+ ```
32
+
33
+ This creates the single framework table `plutonium_wizard_sessions`. See [Storage & config](/reference/wizard/storage-config) for the details.
34
+
35
+ ::: warning Schedule the SweepJob for save-as-you-go wizards
36
+ For plain `execute`-only wizards, leaving the sweep unscheduled only leaves stale session rows (harmless). But if you use per-step `on_submit` (which creates **real records mid-flow**), `Plutonium::Wizard::SweepJob` is the **only** thing that cleans up abandoned partial records. Schedule it as a recurring job. See [Storage & config](/reference/wizard/storage-config#sweepjob).
37
+ :::
38
+
39
+ ## A minimal wizard
40
+
41
+ The common case writes nothing until the end. Steps collect `data`; one `execute` does all the writes in a single transaction.
42
+
43
+ ```ruby
44
+ # app/wizards/company_onboarding_wizard.rb
45
+ class CompanyOnboardingWizard < Plutonium::Wizard::Base
46
+ presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
47
+
48
+ step :company, label: "Company details" do
49
+ attribute :name, :string
50
+ attribute :subdomain, :string
51
+ input :name
52
+ input :subdomain
53
+ validates :name, :subdomain, presence: true
54
+ end
55
+
56
+ step :plan, label: "Plan" do
57
+ attribute :plan, :string
58
+ input :plan, as: :radio_buttons, choices: %w[free pro]
59
+ validates :plan, presence: true
60
+ end
61
+
62
+ review label: "Review & submit"
63
+
64
+ def execute
65
+ company = Company.create!(name: data.company.name, subdomain: data.company.subdomain, plan: data.plan.plan)
66
+ succeed(company).with_message("You're all set!")
67
+ end
68
+ end
69
+ ```
70
+
71
+ - A wizard is a plain class — `< Plutonium::Wizard::Base`. There is no generator (just like interactions); author it by hand.
72
+ - `presents label:/icon:` sets the launch button's label and icon, exactly like interactions; an optional `description:` renders as the wizard's header subheading.
73
+ - Each `step :key, label: do ... end` is one screen. Inside the block, declare its fields with the same DSL you use on a definition or interaction.
74
+ - `data` is **step-keyed**: `data.company.name` reads the **typed** value entered on the `:company` step (cast to the declared type), available from any step and from `execute`. Each step has its own sub-object, so two steps may use the same field name without colliding.
75
+ - `review` is a built-in terminal step (auto-summary + gated Finish). It must be **last**.
76
+ - `execute` runs once at the end and returns an `Outcome` (`succeed(...)` / `failed(...)`). **Use bang methods** (`create!`/`update!`) — failure is signalled by a raised exception, never a return value.
77
+
78
+ ::: warning Use bang methods in `execute`
79
+ The engine detects failure by a **raised exception**. Non-bang `create`/`save`/`update` return `false` on failure without raising — the engine can't see that, treats the step as successful, and advances, silently losing the data. Always use `create!`/`update!`/`save!`, or call `fail!("message")`.
80
+ :::
81
+
82
+ Each step renders as a focused card with a numbered stepper rail (the terminal `review` shows a finish flag, not a number) and a Back / Next / Cancel strip:
83
+
84
+ ![A wizard step page — numbered stepper rail, a focused step card with typed inputs, and Back/Next/Cancel navigation](/images/guides/wizards-step.png)
85
+
86
+ ## Branching with `condition:`
87
+
88
+ A step's `condition:` lambda decides whether the step is included. Branching is **subtractive** — a falsy `condition:` removes the step from the visible path.
89
+
90
+ ```ruby
91
+ step :plan, label: "Plan" do
92
+ attribute :plan, :string
93
+ input :plan, as: :radio_buttons, choices: %w[free pro]
94
+ validates :plan, presence: true
95
+ end
96
+
97
+ # Only shown when the user picked "pro".
98
+ step :billing, label: "Billing", condition: -> { data.plan.plan == "pro" } do
99
+ attribute :card_token, :string
100
+ input :card_token
101
+ validates :card_token, presence: true
102
+ end
103
+ ```
104
+
105
+ ::: warning `condition:` lambdas must be nil-safe
106
+ A `condition:` runs against the typed `data` snapshot at **every** transition — including before its deciding step has been filled, when the value is still `nil`. `-> { data.plan.plan == "pro" }` is fine (`nil == "pro"` is `false`); `-> { data.plan.plan.upcase == "PRO" }` raises on `nil`. Always write conditions that tolerate `nil`.
107
+ :::
108
+
109
+ The condition can also read `anchor` (for [anchored wizards](#anchored-wizards)). Data belonging to branch-hidden steps is pruned before `execute`, so `execute` only ever sees data for steps that actually applied.
110
+
111
+ ## Reusing a model's fields — `using:`
112
+
113
+ Instead of re-declaring fields a model already defines, import them with `using:`. It is a **step option** (not a block method), and it targets a **model (record class) only**.
114
+
115
+ ```ruby
116
+ # Whole-step import — no block needed.
117
+ step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
118
+
119
+ # Mix imported + wizard-local fields: using: plus a block for the extras.
120
+ step :details, label: "Details", using: Company, only: %i[tagline] do
121
+ attribute :referral_code, :string
122
+ input :referral_code
123
+ end
124
+ ```
125
+
126
+ What `using:` imports from the model:
127
+
128
+ - **Field universe + types** from `Model.attribute_names` / `Model.attribute_types`. Selectors `fields:` (alias `only:`) and `except:` pick a subset.
129
+ - **Input styling** overlaid from the auto-resolved `<Model>Definition` (its `as:`, options, labels) — best-effort; no definition found is fine.
130
+ - **Validations** run via a transient `Model.new(slice).valid?`, keeping errors on the imported fields plus `:base`. Pass `validate: false` to skip and write your own inline `validates`.
131
+ - **`form_layout`** inherited from the `<Model>Definition` (filtered to imported fields). Pass `layout: false` to opt out.
132
+
133
+ `using:` is **declaration reuse only** — it never pulls in the model's persistence or callbacks. Data still stages into `data`; your `execute` does the writes. Full detail: [DSL reference › `using:`](/reference/wizard/dsl#using-a-model).
134
+
135
+ ## Sectioning a step — `form_layout`
136
+
137
+ A step is its own form, so you can group its fields with the same `form_layout` DSL you use on a definition, scoped to that step:
138
+
139
+ ```ruby
140
+ step :company, label: "Company details" do
141
+ attribute :name, :string
142
+ attribute :subdomain, :string
143
+ input :name
144
+ input :subdomain
145
+ validates :name, :subdomain, presence: true
146
+
147
+ form_layout do
148
+ section :identity, :name, :subdomain, label: "Identity", columns: 2
149
+ end
150
+ end
151
+ ```
152
+
153
+ ## Repeatable / structured fields
154
+
155
+ Because a step uses the existing form pipeline, `structured_input` works inside a step. The values land in `data.<step>.<name>` as an array of typed sub-objects:
156
+
157
+ ```ruby
158
+ step :team, label: "Invite your team" do
159
+ structured_input :invites, repeat: 5 do |f|
160
+ f.input :email, as: :email
161
+ f.input :role, as: :select, choices: %w[admin member]
162
+ end
163
+ end
164
+
165
+ def execute
166
+ company = Company.create!(name: data.company.name)
167
+ data.team.invites.each { |i| company.invites.create!(email: i.email, role: i.role) }
168
+ succeed(company)
169
+ end
170
+ ```
171
+
172
+ Repeater rows rehydrate from staged `data` on GET, so navigating back (or resuming) re-renders the rows you already filled.
173
+
174
+ ![A structured/repeater step — multiple invite rows with Add/Remove, inside the wizard step card](/images/guides/wizards-repeater.png)
175
+
176
+ ## File uploads (attachments)
177
+
178
+ A step can collect a file. You declare it like any other field — a **`:string`** attribute (it holds the upload **token**, not the bytes) plus a file input:
179
+
180
+ ```ruby
181
+ step :photo, label: "Photo" do
182
+ attribute :photo, :string
183
+ input :photo, as: :file # also: as: :uppy / as: :attachment
184
+ end
185
+ ```
186
+
187
+ A wizard stages its `data` as JSON across several requests, so a file can't ride along — only a **token** does. The field stages the backend's upload token (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data); your `execute` assigns that token to the model's attachment, which both backends accept natively:
188
+
189
+ ```ruby
190
+ def execute
191
+ member = Member.create!(name: data.profile.name)
192
+ member.photo.attach(data.photo.photo) if data.photo.photo.present? # ActiveStorage
193
+ # or, with active_shrine: Member.create!(photo: data.photo.photo)
194
+ succeed(member)
195
+ end
196
+ ```
197
+
198
+ The review summary and the step's preview (when you go Back or resume) render the file for you — reading `data.photo.photo` resolves the token to a displayable attachment automatically.
199
+
200
+ ### Server-side vs direct upload
201
+
202
+ The same field works two ways:
203
+
204
+ - **Server-side (default)** — `input :photo, as: :file`. The file is submitted with the step (a plain file input) and the wizard uploads it to the backend's cache while staging. Nothing else to wire up; works for both ActiveStorage and active_shrine.
205
+ - **Direct upload** — `input :photo, as: :uppy, direct_upload: true, endpoint: "/upload"`. The browser uploads straight to the endpoint (with a progress UI) and posts back a token. Use this for large files or an async UX; it needs the backend's direct-upload endpoint reachable (ActiveStorage's direct uploads, or Shrine's `upload_endpoint`).
206
+
207
+ ::: tip Match the backend to the model
208
+ In server-side mode the backend defaults to `config.wizards.attachment_backend` — auto-detected as Shrine when active_shrine is installed, else ActiveStorage. Override per field with `backend:` (`input :photo, as: :file, backend: :active_storage`). It must match the model your `execute` assigns to: an ActiveStorage model can't accept a Shrine token, and vice-versa.
209
+
210
+ For Shrine, you can also cache through a specific uploader — `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` — so that uploader's cache-stage plugins (mime/dimension extraction, `generate_location`, processing) run while staging. The minted token stays uploader-agnostic, so display and `execute` promotion are unchanged. That uploader's **validations are enforced on the step** too: a file that violates them is rejected right there with a field error (validated against the field's effective uploader — its `uploader:`, or base `Shrine`), rather than slipping through to `execute`.
211
+ :::
212
+
213
+ For **multiple** files, use an array attribute with `multiple: true`; the staged value is then an array of tokens. A staged-but-abandoned upload (cancel/sweep) is an unattached blob / cached file that each storage backend's own cleanup reaps.
214
+
215
+ ## The review step
216
+
217
+ `review` is a built-in terminal step. It:
218
+
219
+ - Renders a read-only auto-summary of every visible step's data (reusing display components). The custom block, if any, renders **below** the summary.
220
+ - Lists invalid/unvisited visible steps as "fix this" jump links.
221
+ - Disables Finish until all visible steps are valid; clicking it runs `execute`.
222
+
223
+ ![The review step — a grouped auto-summary of every step's data with per-step Edit links and a gated Finish](/images/guides/wizards-review.png)
224
+
225
+ ```ruby
226
+ review label: "Review & submit"
227
+
228
+ # Custom content BELOW the auto-summary:
229
+ review label: "Review & submit" do |wizard|
230
+ "By submitting you agree to the #{wizard.data.plan.plan} plan terms."
231
+ end
232
+ ```
233
+
234
+ You can hand the body fully to your own design. The custom block sits below the summary by default; `summary: false` lets it **replace** the summary, and `header: false` drops the step-header (label + prompt). With `summary: false` and no block you get a built-in "ready to complete" panel. Pair with the wizard-level `stepper false` for a fully chromeless flow:
235
+
236
+ ```ruby
237
+ stepper false # no top rail
238
+ # ...
239
+ review summary: false, header: false # no header, no summary → "ready to complete" panel
240
+ ```
241
+
242
+ See the [DSL reference](/reference/wizard/dsl#review) for the complete state table.
243
+
244
+ ## Per-step writes — `on_submit` / `persist` / `on_rollback`
245
+
246
+ `execute` is the default — atomic, no orphans. Reach for per-step `on_submit` **only** when a real record must exist mid-flow (handing off to an external system that webhooks back, a reviewer who must see partial data, a payload too large for the session row).
247
+
248
+ ```ruby
249
+ class ConfigureCompanyWizard < Plutonium::Wizard::Base
250
+ anchored with: Company
251
+ cleanup_after 7.days
252
+
253
+ step :billing, label: "Billing", condition: -> { anchor.paid_plan? } do
254
+ attribute :card_token, :string
255
+ input :card_token
256
+ validates :card_token, presence: true
257
+
258
+ # Runs when THIS step completes (opt-in save-as-you-go), in its own transaction.
259
+ on_submit do
260
+ charge = PaymentApi.authorize!(anchor, data.billing.card_token)
261
+ fail!("Card was declined") unless charge.ok? # → base error, stays on step
262
+ # `persist` registers the record for resume + cleanup → persisted[:billing]
263
+ persist Billing.create!(company: anchor, token: data.billing.card_token, charge_id: charge.id)
264
+ end
265
+
266
+ # ADDITIONAL cleanup on Cancel/abandonment. The engine ALWAYS destroys the
267
+ # persist'd Billing record — on_rollback is only for side effects it can't see
268
+ # (here, refunding the external charge). It runs BEFORE the destroy, so
269
+ # persisted[:billing] is still alive to read.
270
+ on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
271
+ end
272
+
273
+ def execute
274
+ anchor.update!(configured_at: Time.current)
275
+ succeed(anchor).with_message("Company configured.")
276
+ end
277
+ end
278
+ ```
279
+
280
+ - `on_submit` runs in its own transaction when the step completes. Inside it, `persist record` registers record(s) the engine tracks for resume and cleanup — reachable later as `persisted[:step_key]`.
281
+ - `fail!("msg")` aborts the step with a base (form-level) error; `fail!(:field, "msg")` attaches it to a field. Both roll back the step's transaction and re-render with input intact.
282
+ - The engine **always** destroys every `persist`'d record on rollback (Cancel, abandonment-sweep, branch-prune), in reverse order, via `destroy!` (which respects a model's own soft-delete override). `on_rollback` is an **optional, additive** compensating block for side effects the engine can't see (refund a charge, call an external API), and runs **before** the destroy, so `persisted[:key]` is still alive inside it. Don't destroy the tracked record yourself; the engine does.
283
+ - Because `on_submit` writes mid-flow, it isn't atomic across steps — that's why `cleanup_after` + the SweepJob exist. See [Storage & config](/reference/wizard/storage-config) and the [DSL reference](/reference/wizard/dsl#per-step-hooks).
284
+
285
+ ## Anchored wizards
286
+
287
+ An **anchored** wizard runs against an existing record (like `attribute :resource` on an interaction). Read it via `anchor`.
288
+
289
+ ```ruby
290
+ class ConfigureCompanyWizard < Plutonium::Wizard::Base
291
+ anchored with: Company # operate on a Company
292
+
293
+ step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
294
+
295
+ def execute
296
+ anchor.update!(configured_at: Time.current)
297
+ succeed(anchor)
298
+ end
299
+ end
300
+ ```
301
+
302
+ - `anchored with: Company` → a single type. `anchored with: [Company, Organization]` → polymorphic. `anchored` (no `with:`) → generic, bound at registration.
303
+ - `anchor` raises `Plutonium::Wizard::NotAnchoredError` if the wizard wasn't declared `anchored` — it never returns `nil`.
304
+ - Omit `anchored` for a pure create flow (the wizard creates the records it names itself).
305
+
306
+ See [Anchoring & resume](/reference/wizard/anchoring-resume).
307
+
308
+ ## One-time onboarding + gate
309
+
310
+ A one-time wizard is a keyed wizard (`concurrency_key`) that **retains** its completed row as a durable marker. A controller gate redirects users into it until they finish.
311
+
312
+ ```ruby
313
+ class WelcomeWizard < Plutonium::Wizard::Base
314
+ presents label: "Welcome"
315
+
316
+ concurrency_key { current_user } # the stable row to retain (tenant folded in)
317
+ one_time # retain on completion → run once
318
+
319
+ step :profile, label: "Your profile" do
320
+ attribute :full_name, :string
321
+ input :full_name
322
+ validates :full_name, presence: true
323
+ end
324
+
325
+ review label: "All set?"
326
+
327
+ def execute
328
+ current_user.update!(full_name: data.profile.full_name, onboarded_at: Time.current)
329
+ succeed.with_message("Welcome aboard!")
330
+ end
331
+
332
+ # Standalone wizards have no resource policy — gate entry with `authorize?`.
333
+ def authorize?
334
+ current_user.present?
335
+ end
336
+ end
337
+ ```
338
+
339
+ Gate a controller behind it with the `Plutonium::Wizard::Gate` concern:
340
+
341
+ ```ruby
342
+ module AdminPortal
343
+ class DashboardController < AdminPortal::PlutoniumController
344
+ include Plutonium::Wizard::Gate
345
+ ensure_wizard_completed ::WelcomeWizard
346
+ end
347
+ end
348
+ ```
349
+
350
+ An un-completed user hitting the gate is redirected into the wizard (their destination stashed); on completion they're bounced back (PRG). Completed users pass straight through. Re-opening a finished one-time wizard renders an "already completed" page (override its body with a `completed do |wizard| … end` block) rather than re-running it:
351
+
352
+ ![The "already completed" page for a re-opened one-time wizard — a success badge, the wizard's label, and a Continue button](/images/guides/wizards-completed.png)
353
+
354
+ See [One-time wizards](/reference/wizard/one-time).
355
+
356
+ ## Registration & launch
357
+
358
+ A wizard reaches a user as a **resource action** (the `wizard` macro) or a **route-mounted entry** (`register_wizard`) — inside a portal, or on the main app. A portal mount inherits the portal's auth, tenant scoping, layout, and rendering; a main-app mount runs standalone.
359
+
360
+ ### On a resource — the `wizard` macro
361
+
362
+ Register a wizard on a resource definition. Placement follows `anchored?` automatically: an anchored wizard becomes a **record** action (the show page *and* each index row, like `edit`/`destroy`); a non-anchored wizard becomes a collection-level **resource** action.
363
+
364
+ ```ruby
365
+ class CompanyDefinition < Plutonium::Resource::Definition
366
+ wizard :configure, ConfigureCompanyWizard # anchored → record action (/companies/:id/wizards/configure/:step)
367
+ wizard :onboard, CompanyOnboardingWizard # no anchor → resource action (/companies/wizards/onboard/:step)
368
+ end
369
+ ```
370
+
371
+ ![A resource index — each row carries the anchored wizard's launch action (Show · Edit · Configure widget · ⋮)](/images/guides/wizards-index-action.png)
372
+
373
+ The anchor resolves through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe — an out-of-scope or missing id 404s), and the action is gated by a policy predicate named after the wizard key (`def configure? = update?`). For placement flags, routes, and the full option list see [Registration & launch › the `wizard` macro](/reference/wizard/registration-launch#on-a-resource-the-wizard-macro).
374
+
375
+ ### Route-mounted — `register_wizard`
376
+
377
+ For a wizard not tied to a single resource (onboarding, welcome, set-up), mount it alongside `register_resource` — in a portal engine's routes or on the main app:
378
+
379
+ ```ruby
380
+ # packages/admin_portal/config/routes.rb
381
+ AdminPortal::Engine.routes.draw do
382
+ register_wizard ::OnboardOrganizationWizard, at: "onboarding" # in-shell (portal default)
383
+ register_wizard ::SetupOrgWizard, at: "setup", layout: :basic # bare (BasicLayout)
384
+ end
385
+ ```
386
+
387
+ This draws the step routes within the host and gives you an `onboarding_wizard_path` helper. See [Registration & launch › `register_wizard`](/reference/wizard/registration-launch#route-mounted-register_wizard) for `at:`/`as:`/`public:`/`layout:`, the per-host layout defaults, and the controller override hook.
388
+
389
+ ::: danger Portal-level wizards are open to any authenticated user by default
390
+ A `register_wizard` wizard has no resource policy and **defaults to allowed** — any authenticated portal user can run it. Always define `def authorize?` for anything privileged. (Resource-mounted wizards are gated by their action's policy predicate instead.)
391
+ :::
392
+
393
+ ::: tip Authenticated main-app wizards: define your own controller
394
+ A portal mount inherits the portal's auth; a bare main-app mount has no `current_user`. An authenticated main-app wizard therefore needs you to define `::WizardsController` yourself (`include Plutonium::Wizard::Controller` + your auth concern) — the same "app owns the controller" contract as `register_resource`. See [Hosting & the controller override hook](/reference/wizard/registration-launch#hosting-the-controller-override-hook).
395
+ :::
396
+
397
+ ### Guest (unauthenticated) wizards
398
+
399
+ Wizards require authentication by default — and every resume is **owner-scoped**, so a run id leaked in a URL can't be picked up by another user. Opt into pre-login access with the `anonymous` macro and mount it `public: true` (the default for `anonymous`). A guest run's identity is a server-minted id held in the **Rails session** (never a URL, no leak surface); it may authenticate only at its terminal `execute` (e.g. a signup that creates the account and logs in):
400
+
401
+ ```ruby
402
+ class GuestSignupWizard < Plutonium::Wizard::Base
403
+ anonymous
404
+
405
+ step :account do
406
+ attribute :email, :string
407
+ input :email, as: :email
408
+ validates :email, presence: true
409
+ end
410
+ review label: "Review"
411
+
412
+ def execute
413
+ succeed(Account.create!(email: data.account.email)) # may also sign the user in here
414
+ end
415
+ end
416
+
417
+ register_wizard ::GuestSignupWizard, at: "signup", public: true
418
+ ```
419
+
420
+ Full detail (owner-scoping, session-keying, the synthesized public controller): [Authentication](/reference/wizard/anchoring-resume#authentication) and [the public mount](/reference/wizard/registration-launch#public-mount-for-anonymous-wizards).
421
+
422
+ ### Listing in-progress & resume-or-new
423
+
424
+ Build a "continue where you left off" dashboard with `Plutonium::Wizard.in_progress_for(view_context)` — it derives the owner, tenant scope, and portal from the view context and returns that user's in-progress runs for the current portal, each carrying `label` / `icon` / `current_step` / `updated_at` / `resume_url`:
425
+
426
+ ```ruby
427
+ Plutonium::Wizard.in_progress_for(view_context).each do |entry|
428
+ link_to entry.label, entry.resume_url if entry.resume_url # resume_url is nil when unresolvable here
429
+ end
430
+
431
+ # narrow to one record's unfinished draft (query-time filters, index-covered):
432
+ Plutonium::Wizard.in_progress_for(view_context, wizard: ConfigureCompanyWizard, anchor: @company).first
433
+ ```
434
+
435
+ A **tokened** wizard (no `concurrency_key`) doesn't silently fork on relaunch — by default it shows a resume-or-new chooser when a pending run exists (`on_relaunch :new` opts out). Keyed and guest wizards auto-resume their single run.
436
+
437
+ ![The resume-or-new chooser — pending runs with their current step and a Resume button, plus a Start new action](/images/guides/wizards-chooser.png)
438
+
439
+ See [Anchoring & resume › Listing](/reference/wizard/anchoring-resume#listing-in-progress-wizards) for the full entry fields, portal-scoping rules, `resume_unresolved_reason`, and the filter performance notes.
440
+
441
+ ## Where to go next
442
+
443
+ - [DSL reference](/reference/wizard/dsl) — every macro and accessor.
444
+ - [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys, resume.
445
+ - [Storage & config](/reference/wizard/storage-config) — the table, config, encryption, the sweep.
446
+ - [Registration & launch](/reference/wizard/registration-launch) — the `wizard` macro, `register_wizard`, routes.
447
+ - [One-time wizards](/reference/wizard/one-time) — completion markers + the gate.
@@ -229,7 +229,7 @@ def permitted_associations
229
229
  end
230
230
  ```
231
231
 
232
- Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs.
232
+ Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
233
233
 
234
234
  Each named association must:
235
235
 
@@ -39,6 +39,14 @@ aside: false
39
39
  { name: 'Layouts', link: '/plutonium-core/reference/ui/layouts' },
40
40
  { name: 'Assets', link: '/plutonium-core/reference/ui/assets' },
41
41
  ]},
42
+ { group: 'Wizard', items: [
43
+ { name: 'Overview', link: '/plutonium-core/reference/wizard/' },
44
+ { name: 'DSL', link: '/plutonium-core/reference/wizard/dsl' },
45
+ { name: 'Anchoring & resume', link: '/plutonium-core/reference/wizard/anchoring-resume' },
46
+ { name: 'Storage & config', link: '/plutonium-core/reference/wizard/storage-config' },
47
+ { name: 'Registration & launch', link: '/plutonium-core/reference/wizard/registration-launch' },
48
+ { name: 'One-time', link: '/plutonium-core/reference/wizard/one-time' },
49
+ ]},
42
50
  { group: 'Auth', items: [
43
51
  { name: 'Overview', link: '/plutonium-core/reference/auth/' },
44
52
  { name: 'Accounts', link: '/plutonium-core/reference/auth/accounts' },
@@ -50,6 +58,12 @@ aside: false
50
58
  { name: 'Nested resources', link: '/plutonium-core/reference/tenancy/nested-resources' },
51
59
  { name: 'Invites', link: '/plutonium-core/reference/tenancy/invites' },
52
60
  ]},
61
+ { group: 'Kanban', items: [
62
+ { name: 'Overview', link: '/plutonium-core/reference/kanban/' },
63
+ { name: 'DSL', link: '/plutonium-core/reference/kanban/dsl' },
64
+ { name: 'Positioning', link: '/plutonium-core/reference/kanban/positioning' },
65
+ { name: 'Authorization', link: '/plutonium-core/reference/kanban/authorization' },
66
+ ]},
53
67
  { group: 'Testing', items: [
54
68
  { name: 'Overview', link: '/plutonium-core/reference/testing/' },
55
69
  ]},
@@ -0,0 +1,62 @@
1
+ # Kanban Authorization
2
+
3
+ ## `kanban_move?` policy predicate
4
+
5
+ Every drag-and-drop move is authorized through the `kanban_move?` method on the resource's policy. The default implementation delegates to `update?`:
6
+
7
+ ```ruby
8
+ # Plutonium::Resource::Policy — built-in default
9
+ def kanban_move?
10
+ update?
11
+ end
12
+ ```
13
+
14
+ Override it in your policy to give finer control:
15
+
16
+ ```ruby
17
+ class TaskPolicy < ResourcePolicy
18
+ # Allow all authenticated members to drag cards,
19
+ # but require :admin to open the edit form.
20
+ def kanban_move?
21
+ user.member?
22
+ end
23
+
24
+ def update?
25
+ user.admin?
26
+ end
27
+ end
28
+ ```
29
+
30
+ ## Read-only board
31
+
32
+ When `kanban_move?` returns `false` for the current user, the board is rendered read-only. Cards are displayed but dragging is disabled — no drag handles appear and the Stimulus controller does not register drop zones.
33
+
34
+ ## Authorization flow on a move
35
+
36
+ When a card is dropped, the server:
37
+
38
+ 1. Finds the record within the current authorized scope (the same policy `relation_scope` used by the index action).
39
+ 2. Calls `authorize_current!(record, to: :kanban_move?)`. A `false` result halts the action with HTTP 403.
40
+ 3. Validates the drop against the destination column's `accepts:` policy and `locked:` flag. A rejected drop responds with HTTP 422 and re-renders the source column (the Stimulus controller snaps the card back).
41
+ 4. Enforces the destination column's `wip:` limit (cross-column moves only). Exceeding the WIP cap also responds 422.
42
+ 5. Calls `on_drop` and repositions the record inside a transaction.
43
+
44
+ ## No permitted attributes for moves
45
+
46
+ Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `on_drop` callback is author code that runs with full model access — it is the responsibility of the `on_drop` implementation to assign only the attributes appropriate for a column transition. This is intentional: the callback is trusted Ruby, not user-supplied form data.
47
+
48
+ ## Column-level drop policies
49
+
50
+ The `accepts:`, `locked:`, and `wip:` column options enforce additional constraints beyond `kanban_move?`:
51
+
52
+ | Constraint | What it checks | On failure |
53
+ |------------|---------------|------------|
54
+ | `accepts:` | Source column key is allowed | 422 + card snap-back |
55
+ | `locked:` | Source column is not locked | 422 + card snap-back |
56
+ | `wip:` | Cross-column count within limit | 422 + card snap-back |
57
+
58
+ These checks run server-side after `kanban_move?` succeeds. The client-side Stimulus controller reads `data-kanban-accepts` and `data-kanban-locked` attributes to provide visual drop hints, but the server remains the authority.
59
+
60
+ ## Quick-add authorization
61
+
62
+ The `+ Add` button (shown when `add: true` is set on a column) is only rendered when the current policy's `create?` returns `true`. The new form opened by quick-add is the standard resource new form and goes through the normal creation authorization flow.