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,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Page
6
+ # The full-page wizard step page (§7). Composes the stepper, the current
7
+ # step's form (or the terminal review summary), and the Back / Next / Finish /
8
+ # Cancel navigation strip — all carrying `_direction`. Rendered inside the
9
+ # portal layout exactly like a resource page; in a turbo frame (modal) the
10
+ # layout is dropped by the controller.
11
+ #
12
+ # The step form rides the existing resource-form pipeline through a per-step
13
+ # adapter, seeded from the wizard's typed `data` so inputs (including repeater
14
+ # rows) rehydrate from staged data on GET — the resume/back requirement.
15
+ class Wizard < Plutonium::UI::Page::Base
16
+ # @param runner [Plutonium::Wizard::Runner]
17
+ # @param step_url [String] the current step's POST/GET URL.
18
+ # @param errors [Hash{Symbol=>Array<String>}] runner errors (per-field + :base).
19
+ def initialize(runner:, step_url:, errors: nil, description: nil)
20
+ @runner = runner
21
+ @step_url = step_url
22
+ @errors = errors || {}
23
+ @description = description
24
+ super(page_title: wizard_title, page_description: nil)
25
+ end
26
+
27
+ def view_template(&)
28
+ DynaFrameContent() do
29
+ article(class: "pu-wizard mx-auto max-w-3xl", data: {controller: "wizard"}) do
30
+ render_header
31
+ render_stepper
32
+ render_body
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def wizard_title
40
+ step = @runner.current_step
41
+ [@runner.wizard.class.label, step&.label].compact.join(" — ").presence || "Wizard"
42
+ end
43
+
44
+ # Centered wizard header: the title and the wizard-level description
45
+ # (`presents description:`). The per-step heading lives on the step card.
46
+ def render_header
47
+ div(class: "pu-wizard-header mb-7 text-center") do
48
+ h1(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
49
+ @runner.wizard.class.label
50
+ end
51
+ desc = @description.presence || @runner.wizard.class.description
52
+ if desc.present?
53
+ p(class: "mx-auto mt-1.5 max-w-prose text-[var(--pu-text-muted)]") { desc }
54
+ end
55
+ end
56
+ end
57
+
58
+ def render_stepper
59
+ return unless @runner.wizard.class.stepper?
60
+
61
+ render Plutonium::UI::Wizard::Stepper.new(
62
+ steps: @runner.visible_path,
63
+ current: @runner.current_step,
64
+ visited: @runner.visited_keys,
65
+ # Actually-complete (submitted + valid) steps drive the done-check —
66
+ # `visited` alone would mark a reached-but-unsubmitted branch step done.
67
+ complete: @runner.visible_path.reject(&:review?).select { |s| @runner.step_complete?(s) }.map { |s| s.key.to_s },
68
+ navigation: @runner.wizard.class.navigation,
69
+ step_url: method(:url_for_step)
70
+ )
71
+ end
72
+
73
+ def render_body
74
+ step = @runner.current_step
75
+ if step&.review?
76
+ render_review_body(step)
77
+ else
78
+ render_step_form(step)
79
+ end
80
+ end
81
+
82
+ # The focused content card shared by step + review: a body holding the
83
+ # step heading and content, and a footer nav strip.
84
+ def card_classes
85
+ "pu-wizard-card overflow-hidden rounded-[var(--pu-radius-xl)] " \
86
+ "border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-lg)]"
87
+ end
88
+
89
+ def card_body_classes = "p-6 sm:p-8"
90
+
91
+ # The per-step heading: "Step N of M" + the step's label + its description.
92
+ # The terminal review step is the "finish line", not a numbered step, so it
93
+ # carries no step count (here or in the rail) — just its label + description.
94
+ def render_step_header(step)
95
+ div(class: "mb-6") do
96
+ unless step.review?
97
+ span(class: "text-xs font-bold uppercase tracking-wide text-primary-600 dark:text-primary-400") do
98
+ "Step #{step_position(step)} of #{visible_step_count}"
99
+ end
100
+ end
101
+ h2(class: "mt-1 text-xl font-semibold tracking-tight text-[var(--pu-text)]") { step.label.to_s }
102
+ # The author's own description wins. Otherwise, on a review step, fall
103
+ # back to the "check everything over" prompt ONLY when the summary is
104
+ # shown — when summary is off (the ready panel / custom-only body), that
105
+ # prompt would contradict the body, so we omit it.
106
+ desc = step.description.presence
107
+ desc ||= "Check everything over before you finish." if step.review? && step.summary?
108
+ if desc
109
+ p(class: "mt-1.5 text-sm text-[var(--pu-text-muted)]") { desc }
110
+ end
111
+ end
112
+ end
113
+
114
+ # Counts/positions exclude the review step — it's not a numbered step — so
115
+ # the last real step reads "Step N of N" (not "N of N+1" with a missing N+1).
116
+ def visible_step_count = @runner.visible_path.count { |s| !s.review? }
117
+
118
+ def step_position(step)
119
+ (@runner.visible_path.reject(&:review?).index { |s| s.key.to_s == step.key.to_s } || 0) + 1
120
+ end
121
+
122
+ # --- step form --------------------------------------------------------
123
+
124
+ def render_step_form(step)
125
+ seed_errors!(step)
126
+ div(class: card_classes) do
127
+ div(class: card_body_classes) do
128
+ render_step_header(step)
129
+ render Plutonium::UI::Form::Wizard.new(
130
+ step:,
131
+ # Decorate so attachment fields read as resolved attachments (the
132
+ # Uppy preview rehydrates on Back/resume); other fields pass through.
133
+ data: Plutonium::Wizard::AttachmentData.wrap(@runner.wizard.data[step.key], step),
134
+ action: @step_url,
135
+ fields: step_fields(step),
136
+ errors: form_error_messages(step)
137
+ )
138
+ end
139
+ render_nav(finish: false)
140
+ end
141
+ end
142
+
143
+ # The step's renderable field names: scalar attributes + structured inputs.
144
+ def step_fields(step)
145
+ step.attribute_schema.keys.map(&:to_sym) + step.structured_inputs.keys.map(&:to_sym)
146
+ end
147
+
148
+ # --- review -----------------------------------------------------------
149
+
150
+ def render_review_body(step)
151
+ seed_errors!(step)
152
+ div(class: card_classes) do
153
+ form(action: @step_url, method: "post", id: "wizard-form", data: {controller: "wizard"}) do
154
+ div(class: card_body_classes) do
155
+ render_step_header(step) if step.header?
156
+ render_review_errors
157
+ authenticity_field
158
+ input(type: :hidden, name: "_direction", value: "next", data: {wizard_target: "direction"})
159
+ render Plutonium::UI::Wizard::Review.new(runner: @runner, step_url: method(:url_for_step))
160
+ end
161
+ render_nav(finish: true, embedded: true)
162
+ end
163
+ end
164
+ end
165
+
166
+ # --- navigation strip -------------------------------------------------
167
+
168
+ # @param finish [Boolean] last visible step → primary button is Finish.
169
+ # @param embedded [Boolean] the strip is already inside a <form> (review);
170
+ # otherwise wrap each button in its own posting form.
171
+ def render_nav(finish:, embedded: false)
172
+ finish_disabled = finish && @runner.incomplete_visible_steps.any?
173
+
174
+ div(class: "pu-wizard-nav flex items-center justify-between gap-3 border-t border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-6 py-4 sm:px-8") do
175
+ div(class: "flex items-center gap-2") do
176
+ nav_button("Back", direction: "back", style: "pu-btn-outline", embedded:) if show_back?
177
+ nav_button("Cancel", direction: "cancel", style: "pu-btn-ghost", embedded:)
178
+ end
179
+ div(class: "flex items-center gap-2") do
180
+ if finish
181
+ nav_button("Finish", direction: "next", style: "pu-btn-primary", embedded:, disabled: finish_disabled, name: "finish")
182
+ else
183
+ render_forward_buttons(embedded:)
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ # The forward action(s) on a non-review step. The primary button reads
190
+ # "Save & continue" when revisiting an already-submitted step (the edit is
191
+ # persisted), else "Next". Once EVERY visible step is complete — the
192
+ # post-completion edit case — a "Save & review" shortcut appears that stages
193
+ # this step and jumps straight to review (the primary action then; "Save &
194
+ # continue" steps to the next step as the secondary). The shortcut is hidden
195
+ # when the next step already IS review (it would be redundant).
196
+ def render_forward_buttons(embedded:)
197
+ step = @runner.current_step
198
+ # On a validation-error re-render the rejected input is staged IN MEMORY
199
+ # (so the form keeps what was typed), which flips `submitted?` true — but
200
+ # nothing was persisted, so this isn't a re-edit. Keep "Next" there; the
201
+ # presence of `@errors` is the error-render signal.
202
+ revisiting = @runner.submitted?(step) && @errors.blank?
203
+ continue_label = revisiting ? "Save & continue" : "Next"
204
+
205
+ if review_shortcut?(step)
206
+ nav_button(continue_label, direction: "next", style: "pu-btn-outline", embedded:, name: "next")
207
+ nav_button("Save & review", direction: "next", style: "pu-btn-primary", embedded:, name: "save_review", goto: "review")
208
+ else
209
+ nav_button(continue_label, direction: "next", style: "pu-btn-primary", embedded:, name: "next")
210
+ end
211
+ end
212
+
213
+ # Whether to offer the "Save & review" shortcut: every visible step is
214
+ # complete AND the next visible step isn't already the review (so the
215
+ # shortcut lands somewhere the plain Next wouldn't).
216
+ def review_shortcut?(step)
217
+ return false unless @runner.incomplete_visible_steps.empty?
218
+
219
+ path = @runner.visible_path
220
+ idx = path.index { |s| s.key.to_s == step.key.to_s }
221
+ next_step = path[idx + 1] if idx
222
+ next_step && !next_step.review?
223
+ end
224
+
225
+ def show_back?
226
+ path = @runner.visible_path
227
+ current = @runner.current_step
228
+ idx = path.index { |s| s.key.to_s == current&.key.to_s }
229
+ idx && idx > 0
230
+ end
231
+
232
+ # A nav button. When `embedded`, it is a plain submit inside the surrounding
233
+ # <form> (review). Otherwise it's its own mini-form posting to the step URL
234
+ # so the step form (Next) and Back/Cancel submit independently.
235
+ def nav_button(label, direction:, style:, embedded:, disabled: false, name: nil, goto: nil)
236
+ data = {wizard_nav: name || direction}
237
+ # Back/Cancel post WITHOUT the step's field values, so any unsaved edits
238
+ # on the current step are discarded. Mark them so the (already-attached)
239
+ # dirty-form-guard warns before that loss. Next/Finish save, so no guard.
240
+ data["dirty-form-guard-leave"] = leave_warning(direction) if %w[back cancel].include?(direction)
241
+
242
+ # Finish (→ the created resource) and Cancel (→ out of the flow) redirect
243
+ # AWAY from the wizard, to a page with a different structure. The layout
244
+ # opts into Turbo morphing (`turbo-refresh-method: morph`), and these post
245
+ # to the current URL, so Turbo treats the result as a page refresh and
246
+ # morphs the destination INTO the wizard DOM (nesting it) instead of
247
+ # replacing the page. Opt these submitters out of Turbo so they do a clean
248
+ # full navigation. In-wizard Next/Back stay on Turbo (same structure →
249
+ # morph is correct and smooth).
250
+ data["turbo"] = "false" if exits_wizard?(name, direction)
251
+
252
+ if embedded
253
+ button(
254
+ type: :submit, name: "_direction", value: direction,
255
+ class: "pu-btn pu-btn-md #{style}", disabled: disabled || nil,
256
+ data:
257
+ ) { label }
258
+ elsif direction == "next"
259
+ # Next submits the step form (which holds the inputs). The page's nav
260
+ # Next button is associated with the wizard form via the `form` attr.
261
+ # A `goto` button instead carries `_goto` (and NO `_direction`, since only
262
+ # the clicked button's name/value is submitted) — a blank `_direction`
263
+ # still routes to advance, which honors the `goto` cursor override.
264
+ btn_name, btn_value = goto ? ["_goto", goto] : ["_direction", "next"]
265
+ button(
266
+ type: :submit, form: "wizard-form", name: btn_name, value: btn_value,
267
+ class: "pu-btn pu-btn-md #{style}", disabled: disabled || nil,
268
+ data:
269
+ ) { label }
270
+ else
271
+ # Back/Cancel post on their own — no field validation, so an independent
272
+ # mini-form carrying only _direction is correct. Cancel exits the wizard,
273
+ # so the form itself opts out of Turbo (the submit button alone isn't the
274
+ # navigating element here — the form is).
275
+ form_data = exits_wizard?(name, direction) ? {turbo: "false"} : {}
276
+ form(action: @step_url, method: "post", class: "inline", data: form_data) do
277
+ authenticity_field
278
+ input(type: :hidden, name: "_direction", value: direction)
279
+ button(
280
+ type: :submit,
281
+ class: "pu-btn pu-btn-md #{style}",
282
+ data:
283
+ ) { label }
284
+ end
285
+ end
286
+ end
287
+
288
+ # Whether a nav control redirects OUT of the wizard (Finish → resource,
289
+ # Cancel → exit), as opposed to in-wizard navigation (Next/Back). Such
290
+ # controls must do a full navigation, not a Turbo morph (see {#nav_button}).
291
+ def exits_wizard?(name, direction)
292
+ name.to_s == "finish" || direction == "cancel"
293
+ end
294
+
295
+ # Confirmation copy shown by dirty-form-guard when the current step has
296
+ # unsaved edits and the user clicks a control that abandons them.
297
+ def leave_warning(direction)
298
+ case direction
299
+ when "back" then "You have unsaved changes on this step. Go back and lose them?"
300
+ when "cancel" then "You have unsaved changes. Cancel the wizard and lose them?"
301
+ end
302
+ end
303
+
304
+ # --- errors -----------------------------------------------------------
305
+
306
+ # Push runner errors onto the CURRENT step's typed sub-object (the form
307
+ # object), so the form/field error chrome renders them. A review step has no
308
+ # sub-object (base errors render separately) — no-op there.
309
+ def seed_errors!(step)
310
+ return if @errors.blank?
311
+
312
+ obj = @runner.wizard.data[step.key]
313
+ return unless obj
314
+
315
+ obj.errors.clear
316
+ @errors.each do |attr, messages|
317
+ Array(messages).each { |m| obj.errors.add(attr.to_sym, m) }
318
+ end
319
+ end
320
+
321
+ def form_error_messages(step)
322
+ return nil if @errors.blank?
323
+
324
+ obj = @runner.wizard.data[step.key]
325
+ obj&.errors&.full_messages
326
+ end
327
+
328
+ # Errors from a failed finalize (`execute`), surfaced on the review step —
329
+ # which has no field form to attach them to. Shows ALL messages, not just
330
+ # `:base`: a field-level error from `execute` (e.g. {name: ["has already
331
+ # been taken"]} when a uniqueness check fails) would otherwise be dropped,
332
+ # leaving Finish to silently re-render unchanged ("nothing happens").
333
+ def render_review_errors
334
+ messages = review_error_messages
335
+ return if messages.empty?
336
+
337
+ div(class: "rounded-lg border border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950/30 p-4 mb-4", role: "alert") do
338
+ ul(class: "space-y-1") do
339
+ messages.each { |m| li(class: "text-sm text-danger-700 dark:text-danger-400") { m } }
340
+ end
341
+ end
342
+ end
343
+
344
+ # Every finalize error as a full sentence: a `:base` error renders verbatim;
345
+ # a field error is prefixed with its humanized attribute, mirroring Rails'
346
+ # `full_messages` — so {name: ["has already been taken"]} → "Name has
347
+ # already been taken".
348
+ def review_error_messages
349
+ @errors.flat_map do |attr, msgs|
350
+ base = attr.to_s == "base"
351
+ Array(msgs).map { |m| base ? m : "#{attr.to_s.humanize} #{m}" }
352
+ end
353
+ end
354
+
355
+ def authenticity_field
356
+ token = helpers.form_authenticity_token
357
+ input(type: :hidden, name: "authenticity_token", value: token)
358
+ end
359
+
360
+ # Build the GET URL for another step by swapping the trailing :step segment
361
+ # of the current step URL.
362
+ def url_for_step(step_key)
363
+ base = @step_url.delete_suffix("/#{@runner.current_step&.key}")
364
+ "#{base}/#{step_key}"
365
+ end
366
+
367
+ def page_type = :wizard_page
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Page
6
+ # The "resume or start new" chooser (§4.5), shown at the bare launch URL when
7
+ # a tokened wizard opts in with `on_relaunch :prompt` and the user already has
8
+ # pending (in-progress) runs. Lists each pending run with a Resume link and
9
+ # offers a Start-new button. Keyed/one-time/anchored wizards never reach here
10
+ # (they auto-resume their single keyed run); only tokened wizards can have
11
+ # several concurrent runs to choose between.
12
+ class WizardChooser < Plutonium::UI::Page::Base
13
+ # @param wizard_class [Class] the wizard being launched.
14
+ # @param entries [Array<Plutonium::Wizard::Resume::Entry>] pending runs.
15
+ # @param start_new_url [String] bare launch URL that forces a fresh run.
16
+ def initialize(wizard_class:, entries:, start_new_url:)
17
+ @wizard_class = wizard_class
18
+ @entries = entries
19
+ @start_new_url = start_new_url
20
+ super(page_title: wizard_class.label, page_description: nil)
21
+ end
22
+
23
+ def view_template
24
+ DynaFrameContent() do
25
+ article(class: "pu-wizard pu-wizard-chooser mx-auto max-w-2xl", data: {wizard_chooser: true}) do
26
+ render_header
27
+ div(class: card_classes) do
28
+ render_pending_list
29
+ render_start_new
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def render_header
38
+ div(class: "pu-wizard-header mb-7 text-center") do
39
+ h1(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") { @wizard_class.label }
40
+ p(class: "mx-auto mt-1.5 max-w-prose text-[var(--pu-text-muted)]") do
41
+ "Pick up where you left off, or start a new one."
42
+ end
43
+ end
44
+ end
45
+
46
+ def card_classes
47
+ "pu-wizard-card overflow-hidden rounded-[var(--pu-radius-xl)] " \
48
+ "border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-lg)]"
49
+ end
50
+
51
+ # The pending runs, capped in height and scrollable so a user with many
52
+ # drafts doesn't get an unbounded card (the Start-new footer below it stays
53
+ # in view).
54
+ def render_pending_list
55
+ ul(class: "max-h-96 overflow-y-auto divide-y divide-[var(--pu-border)]") do
56
+ @entries.each { |entry| render_entry(entry) }
57
+ end
58
+ end
59
+
60
+ # One pending run: its current step + when it was last touched, and a Resume
61
+ # link. A run whose mount can't be resolved (no `resume_url`) is shown as a
62
+ # disabled row rather than a dead link.
63
+ def render_entry(entry)
64
+ li(class: "flex items-center justify-between gap-4 px-5 py-4", data: {wizard_chooser_entry: entry.current_step}) do
65
+ div(class: "min-w-0") do
66
+ p(class: "truncate text-sm font-semibold text-[var(--pu-text)]") do
67
+ entry.current_step_label.presence || "In progress"
68
+ end
69
+ p(class: "mt-0.5 text-xs text-[var(--pu-text-muted)]") { "Updated #{updated_ago(entry)} ago" }
70
+ end
71
+ if entry.resume_url.present?
72
+ a(href: entry.resume_url, class: "pu-btn pu-btn-sm pu-btn-outline shrink-0", data: {wizard_chooser_resume: true}) { "Resume" }
73
+ else
74
+ span(class: "pu-btn pu-btn-sm pu-btn-outline shrink-0 opacity-50 cursor-not-allowed") { "Resume" }
75
+ end
76
+ end
77
+ end
78
+
79
+ def render_start_new
80
+ div(class: "border-t border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-5 py-4") do
81
+ a(href: @start_new_url, class: "pu-btn pu-btn-md pu-btn-primary w-full justify-center", data: {wizard_chooser_start_new: true}) do
82
+ "Start new"
83
+ end
84
+ end
85
+ end
86
+
87
+ def updated_ago(entry)
88
+ helpers.time_ago_in_words(entry.updated_at)
89
+ rescue
90
+ "a while"
91
+ end
92
+
93
+ def page_type = :wizard_chooser_page
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Page
6
+ # The "already completed" page for a one-time wizard (§9). Shown when a user
7
+ # re-opens a one-time wizard they've already finished: the completion marker
8
+ # is retained but its `data` is cleared, so there is nothing to review — just
9
+ # a confirmation that the flow is done.
10
+ #
11
+ # Authors can override the body entirely with a `completed do |wizard| … end`
12
+ # block on the wizard class (rendered in this component's Phlex context, with
13
+ # the wizard yielded); otherwise the built-in confirmation renders.
14
+ class WizardCompleted < Plutonium::UI::Page::Base
15
+ # @param runner [Plutonium::Wizard::Runner]
16
+ # @param exit_url [String] where the default "Continue" button points.
17
+ def initialize(runner:, exit_url:)
18
+ @runner = runner
19
+ @wizard = runner.wizard
20
+ @exit_url = exit_url
21
+ super(page_title: @wizard.class.label, page_description: nil)
22
+ end
23
+
24
+ def view_template
25
+ DynaFrameContent() do
26
+ article(class: "pu-wizard pu-wizard-completed mx-auto max-w-2xl") do
27
+ div(class: card_classes) do
28
+ div(class: "px-6 py-10 text-center sm:px-10") do
29
+ block = @wizard.class.completed_block
30
+ if block
31
+ instance_exec(@wizard, &block)
32
+ else
33
+ render_default
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def card_classes
44
+ "pu-wizard-card overflow-hidden rounded-[var(--pu-radius-xl)] " \
45
+ "border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-lg)]"
46
+ end
47
+
48
+ # The built-in confirmation body: a success badge, the wizard title, a short
49
+ # message, and a Continue button out.
50
+ def render_default
51
+ render_check_badge
52
+ h1(class: "mt-5 text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
53
+ @wizard.class.label
54
+ end
55
+ p(class: "mx-auto mt-2 max-w-prose text-[var(--pu-text-muted)]") do
56
+ "You've already completed this — there's nothing more to do here."
57
+ end
58
+ div(class: "mt-7") do
59
+ # Exits the wizard to a different page; opt out of Turbo morph (which
60
+ # would otherwise nest the destination into this page — see Page::Wizard).
61
+ a(href: @exit_url, class: "pu-btn pu-btn-md pu-btn-primary", data: {wizard_completed: "exit", turbo: "false"}) { "Continue" }
62
+ end
63
+ end
64
+
65
+ # A success-tinted circular checkmark badge.
66
+ def render_check_badge
67
+ div(class: "mx-auto grid h-16 w-16 place-items-center rounded-full bg-success-100 text-success-600 dark:bg-success-900/30 dark:text-success-400") do
68
+ svg(
69
+ class: "h-8 w-8",
70
+ xmlns: "http://www.w3.org/2000/svg",
71
+ fill: "none",
72
+ viewbox: "0 0 24 24",
73
+ stroke: "currentColor",
74
+ stroke_width: "2.5",
75
+ aria_hidden: "true"
76
+ ) do |s|
77
+ s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 12.75l6 6 9-13.5")
78
+ end
79
+ end
80
+ end
81
+
82
+ def page_type = :wizard_completed_page
83
+ end
84
+ end
85
+ end
86
+ end
@@ -12,7 +12,7 @@ module Plutonium
12
12
  # button). Rows without such a target become a no-op — no
13
13
  # special-casing needed in this layer.
14
14
  def table_body_row_attributes(wrapped_object)
15
- super.merge(data: {controller: "row-click", action: "click->row-click#click"})
15
+ super.merge(data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"})
16
16
  end
17
17
 
18
18
  # Use custom SelectionColumn with Stimulus data attributes
@@ -14,7 +14,8 @@ module Plutonium
14
14
  class ViewSwitcher < Plutonium::UI::Component::Base
15
15
  SEGMENT_LABELS = {
16
16
  table: {label: "Table", icon: Phlex::TablerIcons::Table},
17
- grid: {label: "Grid", icon: Phlex::TablerIcons::LayoutGrid}
17
+ grid: {label: "Grid", icon: Phlex::TablerIcons::LayoutGrid},
18
+ kanban: {label: "Board", icon: Phlex::TablerIcons::LayoutKanban}
18
19
  }.freeze
19
20
 
20
21
  def initialize(views:, current:, cookie_name:, cookie_path: "/")