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,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Sample wizards illustrating the Plutonium::Wizard DSL.
5
+ # Companion to docs/superpowers/specs/2026-06-15-wizard-dsl-design.md.
6
+ # Illustrative only — the subsystem isn't built yet, so this won't load/run.
7
+ #
8
+ # ── DSL cheatsheet ───────────────────────────────────────────────────────────
9
+ # presents label:/icon: chrome (button label + icon), like interactions
10
+ # navigation :linear|:free stepper navigation policy (default :linear)
11
+ # anchored with: Model wizard runs against an existing record (URL :id); read via `anchor`
12
+ # anchored via: :method context anchor resolved by a controller method (portal-level)
13
+ # concurrency_key { … } key a run (tenant folded in); keyed in_progress row is the lock
14
+ # one_time retain the completed row at the key → run once (needs concurrency_key)
15
+ # cleanup_after 7.days|:never idle TTL before abandoned sessions are swept
16
+ # anonymous opt into guest (unauthenticated) access; mount public: true.
17
+ # auth is REQUIRED by default; authed lookups are owner-scoped.
18
+ # a guest wizard may authenticate ONLY at its terminal execute.
19
+ # def authorize? entry gate for portal-level wizards (no resource policy)
20
+ # step :key, label:, condition:, using: do ... end one screen (block declares its
21
+ # fields/hooks); condition: gates it (branching). The block
22
+ # is optional only when `using:` supplies everything.
23
+ # attribute/input/validates/structured_input/form_layout the existing field DSL (in the block)
24
+ # on_submit { ... } OPTIONAL per-step write hook (save-as-you-go)
25
+ # persist record inside on_submit: register record(s) → persisted[:key]
26
+ # fail!("msg") abort the step with an error (sugar over StepError)
27
+ # on_rollback { ... } OPTIONAL extra cleanup of untracked side effects on cancel/abandon
28
+ # (persist'd records are ALWAYS destroyed by the engine regardless)
29
+ # review label: terminal step: auto-summary + gated Finish
30
+ # def execute at-end hook; returns succeed(...) / failed(...) (an Outcome)
31
+ # data.<step>.<field> step-keyed, typed snapshot (e.g. data.company.name);
32
+ # two steps may share a field name without colliding
33
+ # anchor / persisted[:key] the launched-against record / records created via persist
34
+ # ─────────────────────────────────────────────────────────────────────────────
35
+
36
+ # ─────────────────────────────────────────────────────────────────────────────
37
+ # 1. Create flow — execute-only, multi-model, branching.
38
+ # Nothing is written until the end; `execute` does all writes in one
39
+ # transaction. Branching is subtractive via `condition:` (nil-safe).
40
+ # ─────────────────────────────────────────────────────────────────────────────
41
+ class CompanyOnboardingWizard < Plutonium::Wizard::Base
42
+ # `presents` sets the launch button's label + icon (same as interactions).
43
+ presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
44
+
45
+ # Stepper behaviour. :linear = forward + back to visited steps (default).
46
+ navigation :linear
47
+
48
+ # A `step` is one screen. The block declares its fields with the existing
49
+ # field DSL (attribute = typed data, input = how it renders, validates = rules).
50
+ step :company, label: "Company details" do
51
+ attribute :name, :string
52
+ attribute :subdomain, :string
53
+ input :name
54
+ input :subdomain
55
+ validates :name, :subdomain, presence: true
56
+
57
+ # Optional: section THIS step's fields (reuses form_layout, scoped to the step).
58
+ form_layout do
59
+ section :identity, :name, :subdomain, label: "Identity", columns: 2
60
+ end
61
+ end
62
+
63
+ step :plan, label: "Plan" do
64
+ attribute :plan, :string
65
+ input :plan, as: :radio_buttons, choices: %w[free pro]
66
+ validates :plan, presence: true
67
+ end
68
+
69
+ # `condition:` decides whether this step is included (subtractive branching).
70
+ # It runs against the step-keyed `data` snapshot; `data.plan.plan` is nil until
71
+ # the plan step is filled, and `nil == "pro"` is false — so it must be nil-safe.
72
+ step :billing, label: "Billing", condition: -> { data.plan.plan == "pro" } do
73
+ attribute :card_token, :string
74
+ input :card_token
75
+ validates :card_token, presence: true
76
+ end
77
+
78
+ step :team, label: "Invite your team" do
79
+ # `structured_input ..., repeat:` = a repeatable group of sub-fields.
80
+ # Reachable later as data.team.invites (an array of typed sub-objects).
81
+ structured_input :invites, repeat: 5 do |f|
82
+ f.input :email, as: :email
83
+ f.input :role, as: :select, choices: %w[admin member]
84
+ end
85
+ end
86
+
87
+ # `review` is a built-in terminal step: auto-summarises everything entered and
88
+ # gates the Finish button until all visible steps are valid. Must be last.
89
+ review label: "Review & submit"
90
+
91
+ # `execute` runs once at the end, in one transaction. Use bang methods so a
92
+ # failure raises → the engine rolls back and re-renders with the error.
93
+ # Return an Outcome: succeed(value) / failed(errors).
94
+ def execute
95
+ company = Company.create!(name: data.company.name, subdomain: data.company.subdomain, plan: data.plan.plan)
96
+ Billing.create!(company:, token: data.billing.card_token) if data.plan.plan == "pro"
97
+ data.team.invites.each { |i| company.invites.create!(email: i.email, role: i.role) }
98
+ succeed(company).with_message("You're all set!") # with_message → flash
99
+ end
100
+ end
101
+
102
+ # ─────────────────────────────────────────────────────────────────────────────
103
+ # 2. Anchored, save-as-you-go flow with field reuse + cleanup.
104
+ # Launched against an existing Company (the `anchor`). Uses `using:` to import
105
+ # a definition's fields/validations/layout instead of re-declaring them, and
106
+ # per-step `on_submit`/`persist`/`on_rollback` to write as it goes.
107
+ # ─────────────────────────────────────────────────────────────────────────────
108
+ class ConfigureCompanyWizard < Plutonium::Wizard::Base
109
+ presents label: "Configure", icon: Phlex::TablerIcons::Settings
110
+
111
+ # `anchored with:` binds the wizard to an existing record; `anchor` returns it.
112
+ # (Calling `anchor` on a non-anchored wizard raises NotAnchoredError.)
113
+ anchored with: Company
114
+
115
+ # Idle sessions are swept after this long (records created so far are rolled
116
+ # back). Use :never to keep partial records indefinitely.
117
+ cleanup_after 7.days
118
+
119
+ # `using:` imports a field surface from a model (types + validations from the
120
+ # model; input styling from its auto-resolved CompanyDefinition) — no need to
121
+ # re-declare. `fields:` selects a subset. Persistence stays wizard-side; only
122
+ # the declarations are reused.
123
+ step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
124
+
125
+ step :billing, label: "Billing", condition: -> { anchor.paid_plan? } do
126
+ attribute :card_token, :string
127
+ input :card_token
128
+ validates :card_token, presence: true
129
+
130
+ # `on_submit` runs when THIS step completes (opt-in save-as-you-go).
131
+ on_submit do
132
+ charge = PaymentApi.authorize!(anchor, data.billing.card_token)
133
+ fail!("Card was declined") unless charge.ok? # abort with a base error
134
+ # `persist` registers the record for resume + cleanup → persisted[:billing]
135
+ persist Billing.create!(company: anchor, token: data.billing.card_token, charge_id: charge.id)
136
+ end
137
+
138
+ # ADDITIONAL cleanup if the wizard is cancelled/abandoned. The engine ALWAYS
139
+ # destroys the persisted Billing record on rollback — on_rollback is only for
140
+ # side effects the engine can't see (here: refunding the external charge). It
141
+ # runs BEFORE the destroy, so `persisted[:billing]` is still alive to read.
142
+ on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
143
+ end
144
+
145
+ def execute
146
+ anchor.update!(configured_at: Time.current)
147
+ succeed(anchor).with_message("Company configured.")
148
+ end
149
+ end
150
+
151
+ # ─────────────────────────────────────────────────────────────────────────────
152
+ # 3. One-time, standalone onboarding (no anchor).
153
+ # Mounted on its own route; gated so a user only sees it until completed.
154
+ # ─────────────────────────────────────────────────────────────────────────────
155
+ class WelcomeWizard < Plutonium::Wizard::Base
156
+ presents label: "Welcome"
157
+
158
+ # Keyed per user (tenant folded in); `one_time` retains the completed row as a
159
+ # durable "done" marker the gate (below) checks — so it never re-runs.
160
+ concurrency_key { current_user }
161
+ one_time
162
+
163
+ # Portal-level wizards have no resource policy, so gate entry with `authorize?`.
164
+ def authorize? = current_user.present?
165
+
166
+ step :profile, label: "Your profile" do
167
+ attribute :full_name, :string
168
+ attribute :timezone, :string
169
+ input :full_name
170
+ input :timezone, as: :select, choices: ActiveSupport::TimeZone.all.map(&:name)
171
+ validates :full_name, presence: true
172
+ end
173
+
174
+ step :preferences, label: "Preferences" do
175
+ attribute :newsletter, :boolean, default: true
176
+ input :newsletter, as: :toggle
177
+ end
178
+
179
+ review label: "All set?"
180
+
181
+ def execute
182
+ current_user.update!(
183
+ full_name: data.profile.full_name, timezone: data.profile.timezone,
184
+ newsletter: data.preferences.newsletter, onboarded_at: Time.current
185
+ )
186
+ succeed.with_message("Welcome aboard!")
187
+ end
188
+ end
189
+
190
+ # ─────────────────────────────────────────────────────────────────────────────
191
+ # 4. Guest (anonymous) signup — runs PRE-LOGIN, mounted on a public route.
192
+ # Auth is required by default; `anonymous` opts out. A guest run's identity is
193
+ # a server-minted, unguessable cookie (httponly/secure/same_site, cleared on
194
+ # completion). The wizard NEVER crosses the auth boundary mid-flow — the only
195
+ # boundary it may cross is its terminal `execute` (here: create + sign in).
196
+ # ─────────────────────────────────────────────────────────────────────────────
197
+ class GuestSignupWizard < Plutonium::Wizard::Base
198
+ presents label: "Sign up"
199
+
200
+ anonymous # may run without a current_user
201
+
202
+ step :account do
203
+ attribute :email, :string
204
+ attribute :password, :string
205
+ input :email, as: :email
206
+ input :password, as: :password
207
+ validates :email, :password, presence: true
208
+ end
209
+
210
+ review label: "Review"
211
+
212
+ def execute
213
+ account = Account.create!(email: data.account.email, password: data.account.password)
214
+ # A real signup would sign the account in here (the host calls Rodauth, which
215
+ # rotates the Rails session) — no special framework handling is needed.
216
+ succeed(account).with_message("Welcome!")
217
+ end
218
+ end
219
+
220
+ # ─────────────────────────────────────────────────────────────────────────────
221
+ # Registration
222
+ # ─────────────────────────────────────────────────────────────────────────────
223
+
224
+ # (a) On a resource definition — the `wizard` macro synthesizes the launch
225
+ # action. Placement mirrors interactions: anchored → record action (per row);
226
+ # no anchor → resource/collection action (index header).
227
+ class CompanyDefinition < Plutonium::Resource::Definition
228
+ wizard :configure, ConfigureCompanyWizard # anchored → record action
229
+ wizard :onboard, CompanyOnboardingWizard # no anchor → collection action
230
+ end
231
+
232
+ # (b) Portal-level — inside a portal engine's routes (alongside register_resource).
233
+ # Runs within the portal (auth/scoping/layout inherited). Portal-relative path.
234
+ # register_wizard WelcomeWizard, at: "welcome"
235
+ #
236
+ # A guest (anonymous) wizard mounts on a PUBLIC route (drawn on the main app,
237
+ # outside the portal's auth constraint, so it's reachable pre-login).
238
+ # public: true is the default for an anonymous wizard.
239
+ # register_wizard GuestSignupWizard, at: "signup", public: true
240
+
241
+ # (c) Gating the one-time wizard — in a portal/ApplicationController. Redirects
242
+ # the user into the wizard until completed, then bounces them back.
243
+ # class DashboardController < PlutoniumController
244
+ # ensure_wizard_completed WelcomeWizard
245
+ # end
@@ -0,0 +1,86 @@
1
+ # Wizard relaunch prompt — "resume or start new" on bare launch
2
+
3
+ ## Problem
4
+
5
+ Tokened (repeatable) wizards — those with **no `concurrency_key`** — mint a fresh
6
+ run on every bare launch (`GET /onboarding`). A user with one or more pending
7
+ (in-progress) runs has no way to resume from the launch URL; each visit forks a
8
+ new run. Keyed wizards (incl. one-time and anchored) already auto-resume their
9
+ single keyed run, so this gap is specific to tokened wizards.
10
+
11
+ ## Goal
12
+
13
+ Let a tokened wizard **opt in** so that a bare launch with ≥1 pending run shows a
14
+ chooser page — list the pending runs (resume any) or start a new one — instead of
15
+ silently forking. `/onboarding` becomes a chooser in that case.
16
+
17
+ ## Decisions (settled in brainstorming)
18
+
19
+ - **Opt-in**, not default. A macro turns it on; default behaviour (fresh run) is
20
+ unchanged, so existing wizards and "run repeatedly" flows don't nag.
21
+ - **Chooser whenever ≥1 pending** (owner- and tenant-scoped). Even a single
22
+ pending run shows the page, so "Start new" stays reachable.
23
+ - **0 pending → fresh run**, exactly as today.
24
+ - **No-op unless it matters**: ignored for keyed wizards (already auto-resume) and
25
+ `anonymous`/guest wizards (session-keyed single run, no owner).
26
+
27
+ ## Design
28
+
29
+ ### 1. DSL — `on_relaunch`
30
+
31
+ ```ruby
32
+ on_relaunch :prompt # show the chooser when pending runs exist
33
+ # omit → :new (default) → always a fresh run
34
+ ```
35
+
36
+ `dsl.rb`: store `@on_relaunch` (default `:new`), inherit it, expose
37
+ `relaunch_prompt?` (`@on_relaunch == :prompt`).
38
+
39
+ ### 2. Launch branch — `driving.rb#wizard_launch`
40
+
41
+ Before building the runner (which mints a token), decide:
42
+
43
+ ```
44
+ require auth
45
+ if relaunch_prompt? && !anonymous? && !concurrency_key?
46
+ && params[:new].blank? && pending_entries.any?
47
+ → render the chooser page (no token minted)
48
+ else
49
+ → (today) build runner → mint token → PRG to first step
50
+ ```
51
+
52
+ - `pending_entries` = `Plutonium::Wizard::Resume.entries_for(owner, scope:)`
53
+ filtered to `current_wizard_class` — reuses the existing listing module, which
54
+ already resolves owner/tenant scoping and per-run `resume_url`s.
55
+ - `params[:new]` present → skip the chooser (the "Start new" path).
56
+
57
+ ### 3. Chooser page — `Plutonium::UI::Page::WizardChooser`
58
+
59
+ Same card aesthetic as `WizardCompleted`. Renders the wizard label/description,
60
+ then a list of pending runs — each row: current-step label + relative
61
+ `updated_at` + a **Resume** link (`entry.resume_url`) — and a primary **Start
62
+ new** button (→ bare launch URL with `?new=1`). No per-row discard in v1 (YAGNI;
63
+ `cancel` makes it easy to add later).
64
+
65
+ ### 4. "Start new" path
66
+
67
+ The Start-new button links to the bare launch URL + `?new=1`. Re-enters
68
+ `wizard_launch`; the `params[:new]` guard skips the chooser; mints a fresh token
69
+ and redirects to the first step — identical to today's launch. GET, like the
70
+ existing launch (no DB row until the first step is submitted → no orphans).
71
+
72
+ ## Testing
73
+
74
+ A tokened dummy wizard with `on_relaunch :prompt`:
75
+
76
+ - 0 pending → fresh redirect (unchanged).
77
+ - ≥1 pending → chooser lists them with resume links + a Start-new control.
78
+ - `?new=1` → fresh redirect even with pending.
79
+ - opt-out wizard → always fresh (no chooser).
80
+ - keyed / anonymous wizard declaring `on_relaunch` → no-op (still auto-resume / fork).
81
+
82
+ ## Out of scope
83
+
84
+ - Per-row discard/cancel from the chooser (v2).
85
+ - Changing keyed/one-time/anchored behaviour (already auto-resume).
86
+ - A standalone dashboard widget (the `Resume` module already covers that).
@@ -0,0 +1,101 @@
1
+ # Wizard attachments: token → attachment bridge
2
+
3
+ Status: **design / implementing**
4
+ Date: 2026-06-18
5
+
6
+ ## Problem
7
+
8
+ A wizard step can render a file input — the real-world pattern (a colleague's):
9
+
10
+ ```ruby
11
+ step :photo, label: "Photo" do
12
+ attribute :photo, :string
13
+ input :photo, as: :uppy, direct_upload: true, hint: "Optional — …"
14
+ end
15
+ ```
16
+
17
+ — but attachments don't display end to end:
18
+
19
+ - The `Uppy` input and `Display::Components::Attachment` expect a value that
20
+ **quacks like an attachment** (`.url`, `.filename`, `.content_type`, …).
21
+ - A wizard stages **plain typed scalars**. A direct-upload field submits its
22
+ backend's upload **token** (an ActiveStorage signed_id, or Shrine cached-file
23
+ JSON), so `data.photo` is a `String`.
24
+ - `SummaryDisplay` (the review auto-summary) hardcodes `f.string_tag` for every
25
+ field — an attachment shows as a raw token, never a preview.
26
+ - On Back/resume, the `Uppy` input calls `.url` on the staged `String` — wrong.
27
+
28
+ ## Backends — there is more than ActiveStorage
29
+
30
+ Plutonium attachment fields can be backed by **ActiveStorage** *or* **active_shrine**
31
+ (Shrine) — see `Plutonium::UI::Avatar.resolve_image_src`, which branches (AS resolves
32
+ its URL via Rails routing; Shrine via its own `#url`). They carry different upload
33
+ tokens, and active_shrine is **not a core-gem dependency** (generator-installed), so
34
+ it isn't exercised by this repo's suite. The bridge must therefore be
35
+ backend-agnostic by construction; the dummy covers the AS path.
36
+
37
+ ## Key decision — model-free resolution by token SHAPE
38
+
39
+ The field is bare (`attribute :photo, :string`, no `using:` model), so there is **no
40
+ model to resolve against** — and we don't need one. The staged token is one of two
41
+ shapes, and they're distinguishable:
42
+
43
+ - **Shrine** cached-file data is **JSON** (`{"id":…,"storage":"cache",…}`) →
44
+ `Shrine.uploaded_file(data)` revives it from the globally-registered storages.
45
+ - An **ActiveStorage** signed_id is **not** JSON → `ActiveStorage::Blob.find_signed`.
46
+
47
+ ```ruby
48
+ Plutonium::Wizard::Attachments.resolve(token) # → [Shrine::UploadedFile | ActiveStorage::Blob]
49
+ # JSON.parse succeeds → Shrine ; else → ActiveStorage ; blank/tampered → dropped
50
+ ```
51
+
52
+ `data` stays plain strings (no custom ActiveModel type; `encrypt_data`, merge, sweep
53
+ untouched). Resolution is **display-only** — staging and `execute` never call it.
54
+
55
+ Why token-shape over a source model: the actual usage is a **modelless** `as: :uppy`
56
+ field, so there's no `using:` model to assign to; and the two token shapes are
57
+ unambiguous, so a model would be ceremony, not information. (A custom Shrine uploader
58
+ with derivative-specific URLs would want its own attacher, but a cached file's `.url`
59
+ from the base `Shrine` is correct for the in-flight preview/review.)
60
+
61
+ ## The four boundaries
62
+
63
+ 1. **Declaration.** `attribute :photo, :string` + `input :photo, as: :uppy`
64
+ (`/:file`/`:attachment`), `direct_upload: true`. Multiple → an array attribute +
65
+ `multiple: true`. An attachment field = a step input whose `as:` is a file alias
66
+ (`Attachments.field?`).
67
+ 2. **Staging.** The submitted token(s) extract through the step form like any other
68
+ field and stage as the string/array — no special handling (verify in a request
69
+ test).
70
+ 3. **Input rehydration (Back/resume).** The wizard step form wraps its data object so
71
+ an attachment field READS as the resolved attachment object(s) (for the `Uppy`
72
+ preview) while every other field reads normally. Render-only: the submitted value
73
+ is still the token the hidden preview field posts, so staging is unaffected.
74
+ 4. **Review.** `SummaryDisplay` detects attachment fields (`field?`) and renders the
75
+ resolved attachment via `Display::Components::Attachment` instead of `string_tag`.
76
+
77
+ ## Execute
78
+
79
+ Author code, unchanged shape: `succeed(Member.create!(photo: data.photo.photo))`.
80
+ The staged token round-trips into the model's attachment natively (AS or Shrine).
81
+
82
+ ## Risks / dependencies
83
+
84
+ - **Direct-upload endpoint.** `Uppy` posts to `/upload` (AS DirectUploads, or Shrine's
85
+ `upload_endpoint`). A shell-less / main-app wizard must have it reachable. Host
86
+ concern; document it.
87
+ - **Orphaned uploads.** A token staged then abandoned (cancel/sweep) leaves an
88
+ unattached blob / cached Shrine file; each backend's own cleanup handles it. Note it.
89
+ - **Bad/expired token** → resolution drops the entry (no raise), so a tampered token
90
+ never 500s the review or the form.
91
+ - **active_shrine untestable in-repo** (not a dep) — the dummy proves the AS path; the
92
+ Shrine branch is unit-stubbed and rides the same `.url`/`.filename` contract
93
+ (confirmed for display by `Avatar.resolve_image_src`).
94
+
95
+ ## Build order
96
+
97
+ 1. `Plutonium::Wizard::Attachments` — `field?` + token-shape `resolve`. ✅ done
98
+ 2. `SummaryDisplay`: attachment-aware rendering + a dummy `as: :uppy` step + review test.
99
+ 3. Input rehydration wrapper + a Back/resume request test.
100
+ 4. Full dummy flow (upload token → review → execute assigns the attachment) +
101
+ integration test. Requires AS tables provisioned in the dummy.
@@ -0,0 +1,220 @@
1
+ # Wizard hosting: controller resolution, identity, and chrome
2
+
3
+ Status: **design / agreed direction** (no code yet)
4
+ Date: 2026-06-18
5
+ Supersedes the "wizards are portal-hosted only (v1)" framing.
6
+
7
+ ## Problem
8
+
9
+ A wizard renders through the Plutonium controller stack (rendering, view-path
10
+ prefix, layout). Today the host controller is **synthesized** at route-draw time,
11
+ and the synthesis assumes a portal context. That leaves three things
12
+ under-specified once wizards run outside a single portal:
13
+
14
+ 1. **Controller resolution** — which controller serves a wizard, and how an app
15
+ customizes it without a generator.
16
+ 2. **Identity / auth** — where `current_user` comes from, and how a pre-login
17
+ (guest) wizard avoids requiring it.
18
+ 3. **Chrome** — whether a wizard renders in the app shell, shell-less full page,
19
+ or embedded as a modal.
20
+
21
+ The guiding facts (verified against the code):
22
+
23
+ - **Authentication is enforced at the route constraint**, not the controller:
24
+ `constraints Rodauth::Rails.authenticate(:acct) { mount Engine }`. The gem adds
25
+ **no** `require_authentication` before_action; controller auth concerns
26
+ (`Plutonium::Auth::Rodauth(:acct)`) only *provide* `current_user`
27
+ (= `rodauth.rails_account`, `nil` when unauthenticated).
28
+ - **`Plutonium::Auth::Public` is just a `current_user => "Guest"` stub.**
29
+ - **The shell lives entirely in the layout** (`resource.html.erb` →
30
+ `ResourceLayout`: sidebar/header/topbar), not in the wizard page. The wizard
31
+ `Page` renders `header → stepper → body` and is layout-agnostic.
32
+ `Plutonium::Core::Controller` sets `layout -> { turbo_frame_request? ? false : "resource" }`.
33
+
34
+ ## Catalogue of supported cases
35
+
36
+ | Surface | Enforcement | Identity (wizard) | Controller (else synthesize) | Chrome |
37
+ |---|---|---|---|---|
38
+ | **Portal standalone** (`register_wizard` in a portal) | portal constraint | authed (owner) | `<Portal>::PlutoniumController` | `:shell` (default) / `:standalone` |
39
+ | **Main-app standalone, authenticated** (`register_wizard` on the app) | route constraint or `require_wizard_authentication!` | authed (owner) | **app-defined** `::WizardsController` | `:shell`* / `:standalone` |
40
+ | **Main-app standalone, public** (`register_wizard public:`/`anonymous`) | none (outside constraints) | `anonymous` (guest token) | bare synthesized | `:standalone` (no shell to embed in) |
41
+ | **Resource-anchored** (`wizard` macro on a definition) | inherits the resource controller | authed (owner) | the **resource controller** (WizardActions concern) | **always embedded** (modal) |
42
+
43
+ \* a bare main-app base has no shell; "in-shell" only applies if the app's
44
+ `::WizardsController` provides one.
45
+
46
+ Two structural truths:
47
+
48
+ - **Portals are "auth or nothing."** You can't carve a public hole inside an
49
+ engine that's wholly behind a constraint, so a *public* wizard is **main-app
50
+ only**. (A portal *may* be mounted without its constraint — a "public portal" —
51
+ in which case `current_user` is still *available*, just possibly `nil`.)
52
+ - **Anchored wizards are not a wizard controller.** They are a concern
53
+ (`WizardActions`) mixed into the resource controller, inheriting its
54
+ auth/scope/layout. They stay out of the wizard-controller hierarchy entirely.
55
+
56
+ ## 1. Controller resolution — override-first, no inheritance chain
57
+
58
+ There is **no single gem "global wizard controller" in the lineage.** A single
59
+ base class can't span portal and main-app contexts: the concrete controller's
60
+ superclass is *reserved* for the auth/context base (the portal's
61
+ `PlutoniumController`, the app's authenticated base, or a bare
62
+ `ActionController::Base`), so wizard behavior must compose via a **module**, not a
63
+ superclass.
64
+
65
+ Resolution per surface:
66
+
67
+ 1. **Use the app's controller if it exists** (const check — works today):
68
+ - Portal: `<Portal>::WizardsController`
69
+ - Main app: `::WizardsController`
70
+ 2. **Else synthesize:**
71
+ ```ruby
72
+ Class.new(context_base) { include Plutonium::Wizard::Controller }
73
+ ```
74
+ where `context_base` is:
75
+ - Portal → `<Portal>::PlutoniumController` (portal auth/scope/layout)
76
+ - Main-app → a **bare** base (`ActionController::Base`), **no auth** — an
77
+ authenticated main-app wizard therefore requires an app-defined
78
+ `::WizardsController` (same contract as `register_resource` controllers).
79
+
80
+ ### The module is the contract
81
+
82
+ `Plutonium::Wizard::Controller` becomes the complete include surface. Including it
83
+ must yield a fully renderable wizard controller, so it pulls in
84
+ `Plutonium::Core::Controller` itself (asset/layout machinery) instead of the
85
+ synthesizer bolting Core on separately. Re-including Core on a portal parent that
86
+ already has it is a harmless no-op, so one module works everywhere.
87
+
88
+ **View-prefix note.** The gem's shared partials (`plutonium/_flash`, …) are looked
89
+ up by a `"plutonium"` view *prefix*, which normally comes from inheriting a
90
+ controller whose `controller_path` is `"plutonium"` (the app's
91
+ `PlutoniumController`) — *not* from `Core`'s `append_view_path` (that adds the
92
+ directory, not the prefix). A truly bare host lacks the ancestor, so the **module
93
+ contributes the `"plutonium"` prefix itself** (overriding `_prefixes`). This is
94
+ what makes "main-app can be bare" actually render.
95
+
96
+ - **Synthesizer:** `Class.new(context_base) { include Plutonium::Wizard::Controller }`
97
+ - **User override:** `class WizardsController < MyAuthBase; include Plutonium::Wizard::Controller; end`
98
+
99
+ ### Convenience base class (sugar only)
100
+
101
+ For apps that need no custom auth base, ship a ready-made class:
102
+
103
+ ```ruby
104
+ # Plutonium-provided
105
+ class Plutonium::Wizard::BaseController < ActionController::Base
106
+ include Plutonium::Wizard::Controller
107
+ end
108
+
109
+ # app
110
+ class WizardsController < Plutonium::Wizard::BaseController; end
111
+ ```
112
+
113
+ The class is convenience; the **module is the mechanism**.
114
+
115
+ ## 2. Identity / auth — guest-ness belongs to the wizard
116
+
117
+ `current_user` has two distinct meanings:
118
+
119
+ - **Available** = a *controller* property: present wherever a `Rodauth(acct)`
120
+ concern is mixed in (returns `nil` when unauthenticated).
121
+ - **Expected** = a *wizard* property: a non-`anonymous` wizard owner-scopes off
122
+ `current_user`; an `anonymous` wizard *ignores it* and keys identity off the
123
+ session token (owner `nil`).
124
+
125
+ Therefore:
126
+
127
+ - Guest-ness is enforced **in the driving layer**: an `anonymous` wizard never
128
+ consults `current_user` for identity (it's session-token keyed, owner `nil`).
129
+ - A normal wizard with no `current_user` is rejected by the route constraint
130
+ (authenticated mounts) or by `require_wizard_authentication!` (the fallback that
131
+ also covers public mounts hosting a non-anonymous wizard).
132
+ - The wizard module supplies a **default `current_user`** that defers to the
133
+ host's auth concern when present (`defined?(super) ? super : nil`) and is `nil`
134
+ on a bare host — so a bare main-app/public controller has the method without
135
+ shadowing a real auth concern.
136
+
137
+ > **CORRECTION (was: "retire `Auth::Public`").** `Plutonium::Auth::Public` is
138
+ > **kept** — it is not wizard-specific: public *portals* (e.g. a storefront) use
139
+ > it, and a wizard can run inside one (a resource-anchored wizard on a public
140
+ > portal), where `current_user` is the `"Guest"` sentinel. So the driving layer's
141
+ > `current_user_present_for_wizard?` **keeps** its `!= "Guest"` check. What changed
142
+ > is only that the public *wizard* synthesis no longer *needs* `Auth::Public` for
143
+ > correctness (an `anonymous` wizard never reads `current_user`); it's retained
144
+ > there for a defined `current_user` and consistency with public portals.
145
+
146
+ ## 3. Chrome — a `register_wizard` option, three modes
147
+
148
+ > **NAMING (as-built):** the option shipped as **`layout:` — a Rails layout NAME**,
149
+ > exactly like the controller `layout` macro (not a closed enum). It went `chrome:`
150
+ > → `shell:` → `layout:`: `chrome:` was disliked; `shell:` collided with the
151
+ > framework's `shell` chrome-variant enum (`:modern`/`:plain`/`:classic`), a
152
+ > different axis (*which* chrome vs *whether*); `layout:` names what it controls and
153
+ > the value *is* the layout (`:basic` ↔ `basic.html.erb`/`BasicLayout`, like
154
+ > `resource` ↔ `ResourceLayout`). The three "modes" below are: embedded (automatic,
155
+ > turbo-frame), `layout: :basic` (bare), and the host default / `:resource` (shell).
156
+ > Read "chrome" as the layout choice and `chrome: :standalone` as `layout: :basic`
157
+ > throughout this section.
158
+
159
+ The shell is a layout concern, so chrome = layout selection, made by the **driving
160
+ layer at render time** (works regardless of which controller serves the wizard,
161
+ and without touching the `Page` component):
162
+
163
+ | Mode | Appearance | `layout:` |
164
+ |---|---|---|
165
+ | **Embedded** | overlays the current page (launched from a button) | `false` (turbo-frame) — already automatic |
166
+ | **In-shell** | sidebar + topbar + wizard | `:resource` (or omit on a portal) — the `resource` layout |
167
+ | **Bare** | no sidebar/topbar — e.g. "set up your organization" | `:basic` — `BasicLayout` |
168
+
169
+ Rules:
170
+
171
+ - **Chrome is toggled only on `register_wizard`** (`chrome: :shell | :standalone`).
172
+ It travels with the mount, not the wizard class.
173
+ - **Resource-defined wizards (`wizard` macro) are always embedded** — launched
174
+ from a record/collection action, overlaying the page. No chrome option.
175
+ - **Turbo-frame requests are always layout-less** regardless of the setting (the
176
+ embedded path).
177
+ - **Default by mount context:** portal standalone → `:shell`; bare main-app
178
+ standalone → `:standalone` (no shell available). Both overridable.
179
+
180
+ ## Resolved decisions
181
+
182
+ 1. **Chrome surface:** `register_wizard` option only; resource wizards always
183
+ embedded. ✔
184
+ 2. **Default full-page chrome:** portal → `:shell`, main-app → `:standalone`. ✔
185
+ 3. **Retire `Auth::Public`:** yes — guest-ness moves to the `anonymous` wizard +
186
+ driving layer; guest = `current_user.nil?`. ✔
187
+
188
+ ## Migration / current state
189
+
190
+ Already on the branch (keep — steps toward this design):
191
+
192
+ - `PublicWizardsController` split from `::WizardsController` (the const collision
193
+ fix). Under this design the public path is "a bare synthesized controller +
194
+ `anonymous` wizard," but the distinct const remains correct.
195
+ - The synthesized controller including `Plutonium::Core::Controller` itself — this
196
+ becomes "the module pulls in Core," i.e. moved into
197
+ `Plutonium::Wizard::Controller`.
198
+
199
+ To revert before implementing:
200
+
201
+ - The `Rodauth(:user)` added to the dummy's `::PlutoniumController` (wrong layer —
202
+ leaks auth into every portal base). Replace the dummy demo with an app-defined
203
+ `::WizardsController` that includes the wizard module + its own auth — which also
204
+ exercises the override hook.
205
+
206
+ ## Implementation outline (for the follow-up, not this doc)
207
+
208
+ 1. Fold `Core::Controller` into `Plutonium::Wizard::Controller`; ship
209
+ `Plutonium::Wizard::BaseController` convenience class.
210
+ 2. Main-app synthesis: bare `ActionController::Base` base (drop the
211
+ `::PlutoniumController` parent); keep the const-check override.
212
+ 3. `register_wizard … chrome:`; driving layer selects the layout
213
+ (`false` / `basic` / inherited) at render.
214
+ 4. Remove `Auth::Public`; route guest identity through `anonymous` + the driving
215
+ layer; treat `current_user.nil?` as guest.
216
+ 5. Dummy: app-defined `::WizardsController` (auth) for the authenticated main-app
217
+ demo; a separate `anonymous` public wizard for the guest demo; revert the
218
+ `::PlutoniumController` change.
219
+ 6. Tests: override-hook is honored; bare main-app wizard is guest; chrome toggles
220
+ pick the right layout; anchored wizard stays embedded.