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,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # The author-facing class macros: `step`, `review`, `anchored`, `navigation`,
6
+ # `cleanup_after`, `concurrency_key`, `one_time`, `encrypt_data`, `anonymous`.
7
+ # Mixed into {Base}.
8
+ module DSL
9
+ extend ActiveSupport::Concern
10
+
11
+ # Sentinel distinguishing `cleanup_after` (read) from `cleanup_after nil`.
12
+ UNSET = Object.new
13
+ private_constant :UNSET
14
+
15
+ # The default concurrency key an anchored wizard gets when it declares no
16
+ # explicit `concurrency_key`: one in-progress run per (anchor, user). The
17
+ # tenant folds in automatically (§4.4), and the anchor's GlobalID is already
18
+ # globally unique, so this is the full identity. Evaluated in the wizard
19
+ # instance context (where `anchor`/`current_user` live).
20
+ IMPLIED_ANCHOR_KEY = -> { [anchor, current_user] }
21
+ private_constant :IMPLIED_ANCHOR_KEY
22
+
23
+ class_methods do
24
+ def steps
25
+ @steps ||= []
26
+ end
27
+
28
+ # Declare an ordered step.
29
+ #
30
+ # `using:` is a step OPTION (never a block method — avoids Ruby's
31
+ # `Module#using` refinements clash). The block, when present, adds inline
32
+ # fields on top. Selector options for `using:` (only:/except:/fields:/etc.)
33
+ # are captured and merged in Task 3.
34
+ def step(key, label: nil, description: nil, condition: nil, using: nil, **using_opts, &block)
35
+ assert_not_after_review!(key)
36
+
37
+ capture = FieldCapture.build(using:, using_opts:, &block)
38
+
39
+ steps << Step.new(
40
+ key:,
41
+ label:,
42
+ description:,
43
+ condition:,
44
+ fields: capture,
45
+ on_submit: capture.delete_hook(:on_submit),
46
+ on_rollback: capture.delete_hook(:on_rollback),
47
+ using_spec: capture.using_spec
48
+ )
49
+ end
50
+
51
+ # Declare the terminal review step (§2.5). Must be last.
52
+ #
53
+ # `summary:` (default true) controls the auto-summary of completed steps in
54
+ # the COMPLETE state: with no custom block, `summary: true` renders the
55
+ # per-step summary, `summary: false` renders the built-in "ready to
56
+ # complete" panel instead — for a fully author-owned review. (The summary
57
+ # always renders in the INCOMPLETE state, where it's the review-and-fix
58
+ # view, regardless of this flag.)
59
+ #
60
+ # `header:` (default true) controls the step-header section (the label +
61
+ # the "check everything over" prompt). `header: false` drops it entirely,
62
+ # leaving just the review body in the card — for a chromeless finish.
63
+ def review(label: "Review", description: nil, condition: nil, summary: true, header: true, &block)
64
+ assert_not_after_review!(:review)
65
+ steps << ReviewStep.new(label:, description:, condition:, summary:, header:, block:)
66
+ end
67
+
68
+ # --- anchoring (§3) ---
69
+
70
+ # Declare that this wizard runs against an anchor record. Two anchoring
71
+ # strategies (which may combine):
72
+ #
73
+ # - `anchored with: Company` — a TYPE anchor. The anchor is resolved from
74
+ # the URL `:id` via the resource controller's scoped, policy-gated
75
+ # `resource_record!` (resource-mounted member route). IDOR-safe because
76
+ # the record is scoped+authorized.
77
+ # - `anchored via: :current_scoped_entity` — a CONTEXT anchor. The anchor
78
+ # is resolved by calling that method on the controller at request time
79
+ # (`:current_user`, `:current_scoped_entity`, or any host method). No
80
+ # `:id`, IDOR-safe (trusted context). Mounted portal-level via
81
+ # `register_wizard`.
82
+ # - combined `anchored via: :current_scoped_entity, with: Organization` —
83
+ # resolve via the method, then assert the result is an Organization.
84
+ #
85
+ # A resolved anchor that is nil raises (anchored-ness is declared).
86
+ def anchored(with: nil, via: nil, &resolver)
87
+ @anchored = true
88
+ @anchor_types = Array(with).presence
89
+ @anchor_via = via
90
+ @anchor_resolver = resolver
91
+ end
92
+
93
+ def anchored? = !!@anchored
94
+
95
+ def anchor_types = @anchor_types
96
+
97
+ # The controller method used to resolve a CONTEXT anchor, or nil for a
98
+ # TYPE (`with:`-only) anchor.
99
+ def anchor_via = @anchor_via
100
+
101
+ # Whether this wizard's anchor is a CONTEXT anchor (resolved via a method),
102
+ # as opposed to a TYPE anchor (resolved from the URL `:id`).
103
+ def anchored_via? = !@anchor_via.nil?
104
+
105
+ def anchor_resolver = @anchor_resolver
106
+
107
+ # --- navigation (§7) ---
108
+
109
+ def navigation(mode = nil)
110
+ if mode
111
+ @navigation = mode
112
+ else
113
+ @navigation || :linear
114
+ end
115
+ end
116
+
117
+ # Whether the top rail (the step indicator, §7) is shown. On by default;
118
+ # `stepper false` hides it for a chromeless flow. Uses UNSET so the `false`
119
+ # value reads back correctly (a plain `|| true` would re-enable it).
120
+ def stepper(flag = UNSET)
121
+ return (@stepper = flag) unless flag.equal?(UNSET)
122
+ return @stepper unless @stepper.nil?
123
+ true
124
+ end
125
+
126
+ def stepper? = stepper
127
+
128
+ # What a bare launch does when the user already has pending (in-progress)
129
+ # runs of this wizard. `:prompt` (default) renders a "resume or start new"
130
+ # chooser so the user's in-progress work isn't silently discarded; `:new`
131
+ # is the explicit opt-out for wizards that always start fresh, minting a new
132
+ # run every time. Only meaningful for authenticated TOKENED wizards — keyed
133
+ # wizards already auto-resume their single keyed run, and `anonymous` runs
134
+ # are session-keyed; the driving layer no-ops the prompt for both. The
135
+ # chooser only appears when a pending run actually exists, so `:prompt` is a
136
+ # safe superset of `:new` (with no pending run it mints fresh either way).
137
+ def on_relaunch(mode = nil)
138
+ return @on_relaunch || :prompt if mode.nil?
139
+ @on_relaunch = mode
140
+ end
141
+
142
+ # Whether a bare launch should show the resume-or-new chooser (§4.5).
143
+ def relaunch_prompt? = on_relaunch == :prompt
144
+
145
+ # --- cleanup (§2.3) ---
146
+
147
+ def cleanup_after(ttl = UNSET)
148
+ if ttl.equal?(UNSET)
149
+ return @cleanup_after_set ? @cleanup_after : Plutonium.configuration.wizards.cleanup_after
150
+ end
151
+ @cleanup_after_set = true
152
+ @cleanup_after = (ttl == :never) ? nil : ttl
153
+ end
154
+
155
+ # --- concurrency (§4.2) ---
156
+
157
+ # Declare the run's CONCURRENCY KEY — the value(s) a run is keyed by
158
+ # (Solid Queue-style). The keyed session row is created at start
159
+ # (`in_progress`) and IS the lock: a second launch with the same key
160
+ # resumes that row instead of forking (at most one in-progress run per
161
+ # key). Omit → unlimited concurrent runs, each identified by a fresh
162
+ # `wizard_token` (§4.3).
163
+ #
164
+ # The resolver runs in the wizard instance context (where `current_user`,
165
+ # `current_scoped_entity`, `anchor`, and `wizard_token` are available) and
166
+ # returns the value(s): records → GlobalID, scalars → to_s, arrays joined.
167
+ # The portal tenant (`current_scoped_entity`) is ALWAYS folded in
168
+ # automatically (§4.4) — authors never thread it.
169
+ #
170
+ # concurrency_key { current_user } # ≤1 in-progress per user
171
+ # concurrency_key { anchor } # ≤1 per anchored record (any user)
172
+ # concurrency_key { wizard_token } # per-run id → tokened/repeatable
173
+ # concurrency_key :current_user # method shorthand
174
+ #
175
+ # An `anchored` (authenticated) wizard with NO explicit key DEFAULTS to
176
+ # `{ [anchor, current_user] }` — one draft per user per record (see
177
+ # {IMPLIED_ANCHOR_KEY}). To make an anchored wizard repeatable instead
178
+ # (a fresh run per launch), declare `concurrency_key { wizard_token }`.
179
+ def concurrency_key(method = nil, &block)
180
+ reject_anonymous_keying!("concurrency_key") if anonymous?
181
+ @concurrency_key =
182
+ if block
183
+ block
184
+ elsif method
185
+ m = method.to_sym
186
+ -> { public_send(m) }
187
+ else
188
+ raise ArgumentError, "concurrency_key requires a block or a method name"
189
+ end
190
+ end
191
+
192
+ # Whether this wizard is keyed (keyed/singleton runs) — an explicit
193
+ # `concurrency_key` OR the implied anchored default.
194
+ def concurrency_key? = !@concurrency_key.nil? || implied_anchor_key?
195
+
196
+ # The resolver proc, or nil when the wizard is tokened. Falls back to the
197
+ # implied `{ [anchor, current_user] }` for an anchored wizard with no
198
+ # explicit key.
199
+ def concurrency_key_resolver
200
+ @concurrency_key || (implied_anchor_key? ? IMPLIED_ANCHOR_KEY : nil)
201
+ end
202
+
203
+ # Whether the implied anchored key applies: the wizard is `anchored`, isn't
204
+ # `anonymous` (a guest has no real user to key by — it stays session-keyed),
205
+ # and declared no explicit `concurrency_key`.
206
+ def implied_anchor_key? = anchored? && !anonymous? && @concurrency_key.nil?
207
+
208
+ # --- repeatability / one-time (§4.3 / §9) ---
209
+
210
+ # Opt a wizard into being ONE-TIME: on successful completion the completed
211
+ # row is RETAINED at its concurrency_key, permanently blocking a restart
212
+ # (and is what the gate, §9, checks). Without `one_time` the row is DELETED
213
+ # on completion → repeatable.
214
+ #
215
+ # Requires a `concurrency_key` (that's the stable row to retain); a run
216
+ # with no concurrency_key is tokened and always repeatable. The
217
+ # requirement is enforced lazily in {#one_time?} so subclass timing and
218
+ # declaration order don't matter.
219
+ def one_time
220
+ reject_anonymous_keying!("one_time") if anonymous?
221
+ @one_time = true
222
+ end
223
+
224
+ # Whether this wizard is one-time. Raises if `one_time` was declared
225
+ # without a `concurrency_key` (only keyed runs can be retained). The
226
+ # `one_time` + `anonymous` conflict is rejected eagerly in the macros
227
+ # (whichever is declared last raises), so it can't reach here.
228
+ def one_time?
229
+ return false unless @one_time
230
+ unless concurrency_key?
231
+ raise ArgumentError,
232
+ "#{name || "wizard"} declares `one_time` without a `concurrency_key`; " \
233
+ "one-time retention needs a stable key to retain (§4.3)"
234
+ end
235
+ true
236
+ end
237
+
238
+ # A custom body for the "already completed" page shown when a finished
239
+ # ONE-TIME wizard is re-opened (§9). The completion marker is retained but
240
+ # its `data` is cleared, so there's nothing to review — just a confirmation.
241
+ # The block renders in the {Plutonium::UI::Page::WizardCompleted} Phlex
242
+ # context (with the wizard yielded) and REPLACES the default body entirely
243
+ # (icon/title/message/button — the author supplies their own). Omit for the
244
+ # built-in confirmation page.
245
+ #
246
+ # completed do |wizard|
247
+ # h1 { "You're all set up!" }
248
+ # a(href: "/dashboard") { "Go to your dashboard" }
249
+ # end
250
+ def completed(&block)
251
+ @completed_block = block
252
+ end
253
+
254
+ # The custom completed-page block, or nil for the built-in default.
255
+ def completed_block = @completed_block
256
+
257
+ # --- authentication (§4.5) ---
258
+
259
+ # Opt this wizard into GUEST (unauthenticated) access. By default a wizard
260
+ # requires a `current_user` to enter — entry without one is rejected. An
261
+ # `anonymous` wizard may run with no `current_user`; its identity is the
262
+ # server-minted `wizard_token` (httponly/secure/same_site cookie), and it
263
+ # may authenticate ONLY at its terminal `execute` (e.g. a signup flow). It
264
+ # NEVER crosses the auth boundary mid-flow (§4.5).
265
+ def anonymous
266
+ reject_anonymous_keying!("anonymous") if @concurrency_key || @one_time
267
+ @anonymous = true
268
+ end
269
+
270
+ def anonymous? = !!@anonymous
271
+
272
+ # `anonymous` is mutually exclusive with `concurrency_key`/`one_time`: a
273
+ # guest's only sound identity is its session token, so the wizard stays
274
+ # session-keyed and repeatable. A custom `concurrency_key` resolved in a
275
+ # guest context (`current_user` → nil) would collide every guest on one
276
+ # run, and `one_time` has no durable principal to retain a completion
277
+ # against. Rejected eagerly so whichever macro is declared LAST raises,
278
+ # regardless of order.
279
+ def reject_anonymous_keying!(macro)
280
+ raise ArgumentError,
281
+ "#{name || "wizard"} declares `#{macro}` together with `anonymous`, which are " \
282
+ "mutually exclusive: a guest's identity is its session token, so the wizard is " \
283
+ "already session-keyed and repeatable. A `concurrency_key` would collide all " \
284
+ "guests on one run, and `one_time` has no durable identity to retain against. " \
285
+ "Drop `anonymous`, or drop `concurrency_key`/`one_time` (§4.5)."
286
+ end
287
+
288
+ # --- encryption (§8.1) ---
289
+
290
+ # Opt this wizard's staged `data` into at-rest encryption (§8.1). Pass
291
+ # `false` to opt OUT explicitly when the app encrypts everything by default
292
+ # (`config.wizards.encrypt_data`). Left unset, the wizard inherits that
293
+ # global default.
294
+ def encrypt_data(flag = true)
295
+ @encrypt_data = flag
296
+ end
297
+
298
+ # Effective at-rest encryption for this wizard: an explicit `encrypt_data`
299
+ # (true) / `encrypt_data false` wins; unset inherits the global default
300
+ # (`config.wizards.encrypt_data`, off unless the app opts in).
301
+ def encrypt_data?
302
+ return !!@encrypt_data unless @encrypt_data.nil?
303
+ Plutonium.configuration.wizards.encrypt_data
304
+ end
305
+
306
+ private
307
+
308
+ def assert_not_after_review!(key)
309
+ return unless steps.any?(&:review?)
310
+ raise ArgumentError,
311
+ "`review` must be the last step; cannot declare step :#{key} after it"
312
+ end
313
+
314
+ # Class-level state must not leak into subclasses by reference.
315
+ def inherited(subclass)
316
+ super
317
+ subclass.instance_variable_set(:@steps, steps.dup)
318
+ subclass.instance_variable_set(:@anchored, @anchored)
319
+ subclass.instance_variable_set(:@anchor_types, @anchor_types)
320
+ subclass.instance_variable_set(:@anchor_via, @anchor_via)
321
+ subclass.instance_variable_set(:@anchor_resolver, @anchor_resolver)
322
+ subclass.instance_variable_set(:@navigation, @navigation)
323
+ subclass.instance_variable_set(:@stepper, @stepper)
324
+ subclass.instance_variable_set(:@on_relaunch, @on_relaunch)
325
+ subclass.instance_variable_set(:@cleanup_after, @cleanup_after)
326
+ subclass.instance_variable_set(:@cleanup_after_set, @cleanup_after_set)
327
+ subclass.instance_variable_set(:@concurrency_key, @concurrency_key)
328
+ subclass.instance_variable_set(:@one_time, @one_time)
329
+ subclass.instance_variable_set(:@completed_block, @completed_block)
330
+ subclass.instance_variable_set(:@encrypt_data, @encrypt_data)
331
+ subclass.instance_variable_set(:@anonymous, @anonymous)
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Raised when a wizard requires an anchor record but none is available.
6
+ class NotAnchoredError < StandardError; end
7
+
8
+ # Raised when the `wizard_class` route default does not resolve to a
9
+ # Plutonium::Wizard::Base subclass (a misconfigured mount, or a tampered
10
+ # path parameter).
11
+ class UnknownWizardError < StandardError; end
12
+
13
+ # Raised when a wizard step fails. Carries the attribute the error should be
14
+ # attached to (defaults to +:base+) so it can be surfaced on a form.
15
+ class StepError < StandardError
16
+ # @return [Symbol] the attribute the error applies to
17
+ attr_reader :attribute
18
+
19
+ # @param message [String, nil] the error message
20
+ # @param attribute [Symbol] the attribute the error applies to
21
+ def initialize(message = nil, attribute: :base)
22
+ @attribute = attribute
23
+ super(message)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Records the field surface declared inside a `step` block.
6
+ #
7
+ # A step block reuses Plutonium's existing field DSL — `attribute`, `input`,
8
+ # `validates`, `structured_input`, `form_layout` — plus the per-step hooks
9
+ # `on_submit` and `on_rollback`. This object captures all of it by
10
+ # `instance_exec`-ing the block against itself.
11
+ #
12
+ # The union `data` schema (§2.6) is built from inline `attribute name, type`
13
+ # declarations recorded here as `attribute_schema` ({name => type}).
14
+ #
15
+ # `using:` import (a model — see FieldImporter) is recorded as a marker
16
+ # (`using_spec`) and merged lazily; this object only captures inline
17
+ # declarations and composes them over the resolved import (inline wins).
18
+ class FieldCapture
19
+ include Plutonium::Definition::DefineableProps
20
+ include Plutonium::Definition::StructuredInputs
21
+ include Plutonium::Definition::FormLayout
22
+
23
+ defineable_props :field, :input
24
+
25
+ attr_reader :validations, :hooks, :using_spec
26
+
27
+ def self.build(using: nil, using_opts: {}, &block)
28
+ capture = new
29
+ capture.record_using(using, using_opts) if using
30
+ capture.instance_exec(&block) if block
31
+ capture
32
+ end
33
+
34
+ def initialize
35
+ @inline_attribute_schema = {}
36
+ @inline_attribute_options = {}
37
+ @validations = []
38
+ @hooks = {}
39
+ end
40
+
41
+ # Inline `attribute :name, :type` — records the union-schema type and any
42
+ # options (default:, etc.), which are threaded into the typed `data`
43
+ # snapshot so e.g. `default:` applies (§2.6).
44
+ def attribute(name, type = :string, **options)
45
+ key = name.to_sym
46
+ @inline_attribute_schema[key] = type
47
+ @inline_attribute_options[key] = options unless options.empty?
48
+ self
49
+ end
50
+
51
+ # The effective union-schema types for this step ({name => type}), composing
52
+ # a `using:` import with inline `attribute` declarations — **inline wins on a
53
+ # name conflict** (§2.4). The imported surface is resolved lazily.
54
+ def attribute_schema
55
+ imported_spec ? imported_spec.attribute_schema.merge(@inline_attribute_schema) : @inline_attribute_schema
56
+ end
57
+
58
+ # The effective per-attribute options ({name => {default:, ...}}). Imports
59
+ # contribute none (types come from the source; options stay inline); inline
60
+ # declarations are returned as-is.
61
+ def attribute_options
62
+ @inline_attribute_options
63
+ end
64
+
65
+ # The effective input config ({name => {options:, block:}}) — imported inputs
66
+ # composed with inline `input`/`field` declarations, inline winning on
67
+ # conflict. Drives the step form (Task 6).
68
+ def inputs
69
+ imported = imported_spec ? imported_spec.inputs : {}
70
+ imported.merge(defined_inputs)
71
+ end
72
+
73
+ # The form_layout for this step (§7.1 resolution order): an inline
74
+ # `form_layout` wins; else the layout inherited from a `using:` source
75
+ # (already filtered to the imported fields); else nil (default single grid).
76
+ def form_layout_sections
77
+ @form_layout || imported_spec&.form_layout
78
+ end
79
+
80
+ # The imported validation runner ({attribute => [messages]} over a data
81
+ # slice), or nil when there's no `using:` import or `validate: false`. The
82
+ # runner (Task 4) combines this with inline `validates`.
83
+ def imported_validate_fn
84
+ imported_spec&.validate_fn
85
+ end
86
+
87
+ # The imported model's form-relevant validators ([[name], options] pairs),
88
+ # replayed onto the typed data class so imported fields render their
89
+ # required/length/etc. metadata. Empty without a `using:` import. Distinct
90
+ # from `validations` (inline, runner-bound) so imports aren't double-validated.
91
+ def imported_form_validators
92
+ imported_spec&.form_validators || []
93
+ end
94
+
95
+ # The resolved `using:` import surface, or nil. Memoized.
96
+ def imported_spec
97
+ return @imported_spec if defined?(@imported_spec)
98
+ @imported_spec =
99
+ if @using_spec
100
+ FieldImporter.resolve(using: @using_spec[:using], opts: @using_spec[:opts])
101
+ end
102
+ end
103
+
104
+ # Inline `validates` — recorded as raw args for the runner (Task 4) to apply.
105
+ def validates(*args, **options)
106
+ @validations << [args, options]
107
+ self
108
+ end
109
+
110
+ # Instance-level structured_input: the step block runs at instance level,
111
+ # but the StructuredInputs concern only exposes a class method. Record into
112
+ # a per-instance registry mirroring `defined_structured_inputs`.
113
+ def structured_input(name, **options, &block)
114
+ unless block || options[:using] || options[:fields]
115
+ raise ArgumentError,
116
+ "`structured_input :#{name}` needs a block, `using:`, or `fields:`"
117
+ end
118
+ instance_structured_inputs[name] = {options:, block:}.compact
119
+ end
120
+
121
+ def defined_structured_inputs
122
+ instance_structured_inputs
123
+ end
124
+
125
+ def on_submit(&block)
126
+ @hooks[:on_submit] = block
127
+ end
128
+
129
+ def on_rollback(&block)
130
+ @hooks[:on_rollback] = block
131
+ end
132
+
133
+ # form_layout is provided by the FormLayout concern as a class method; the
134
+ # step block runs at instance level, so expose an instance-level shim that
135
+ # records onto this capture's own builder.
136
+ def form_layout(&block)
137
+ raise ArgumentError, "`form_layout` requires a block" unless block
138
+ builder = Plutonium::Definition::FormLayout::Builder.new
139
+ builder.instance_exec(&block)
140
+ @form_layout = builder.sections.freeze
141
+ end
142
+
143
+ def record_using(using, opts)
144
+ @using_spec = {using:, opts: opts || {}}
145
+ end
146
+
147
+ # Pop a recorded hook (used by the DSL when building the Step).
148
+ def delete_hook(name) = @hooks.delete(name)
149
+
150
+ private
151
+
152
+ def instance_structured_inputs
153
+ @instance_structured_inputs ||= {}
154
+ end
155
+ end
156
+ end
157
+ end