plutonium 0.61.0 → 0.62.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
  3. data/CHANGELOG.md +27 -0
  4. data/app/assets/plutonium.css +1 -1
  5. data/app/assets/plutonium.js +315 -38
  6. data/app/assets/plutonium.js.map +4 -4
  7. data/app/assets/plutonium.min.js +31 -31
  8. data/app/assets/plutonium.min.js.map +4 -4
  9. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  10. data/app/views/resource/kanban_move_form.html.erb +1 -0
  11. data/config/brakeman.ignore +2 -2
  12. data/docs/.vitepress/config.ts +21 -1
  13. data/docs/.vitepress/sync-skills.mjs +45 -0
  14. data/docs/ai.md +99 -0
  15. data/docs/guides/kanban.md +128 -18
  16. data/docs/reference/kanban/authorization.md +25 -5
  17. data/docs/reference/kanban/dsl.md +49 -8
  18. data/docs/reference/kanban/index.md +3 -3
  19. data/docs/reference/kanban/positioning.md +1 -1
  20. data/docs/reference/resource/definition.md +10 -1
  21. data/docs/reference/resource/model.md +26 -0
  22. data/docs/reference/ui/forms.md +41 -0
  23. data/docs/reference/wizard/dsl.md +5 -0
  24. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  25. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  26. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  27. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  28. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  29. data/lib/plutonium/action/base.rb +8 -0
  30. data/lib/plutonium/configuration.rb +12 -0
  31. data/lib/plutonium/definition/index_views.rb +16 -0
  32. data/lib/plutonium/kanban/column.rb +80 -27
  33. data/lib/plutonium/models/has_cents.rb +30 -2
  34. data/lib/plutonium/resource/controller.rb +22 -1
  35. data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
  36. data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
  37. data/lib/plutonium/resource/policy.rb +6 -0
  38. data/lib/plutonium/routing/mapper_extensions.rb +1 -0
  39. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  40. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  41. data/lib/plutonium/ui/form/base.rb +6 -0
  42. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  43. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  44. data/lib/plutonium/ui/form/components/uppy.rb +20 -2
  45. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  46. data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
  47. data/lib/plutonium/ui/form/resource.rb +12 -0
  48. data/lib/plutonium/ui/form/theme.rb +7 -0
  49. data/lib/plutonium/ui/grid/card.rb +40 -13
  50. data/lib/plutonium/ui/kanban/column.rb +111 -24
  51. data/lib/plutonium/ui/kanban/resource.rb +118 -11
  52. data/lib/plutonium/ui/layout/base.rb +1 -1
  53. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  54. data/lib/plutonium/ui/page/index.rb +1 -1
  55. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  56. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  57. data/lib/plutonium/ui/page/show.rb +7 -2
  58. data/lib/plutonium/ui/table/resource.rb +1 -1
  59. data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
  60. data/lib/plutonium/version.rb +1 -1
  61. data/package.json +5 -3
  62. data/src/css/components.css +5 -0
  63. data/src/js/controllers/currency_input_controller.js +39 -0
  64. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  65. data/src/js/controllers/kanban_controller.js +442 -55
  66. data/src/js/controllers/register_controllers.js +2 -0
  67. data/yarn.lock +674 -4
  68. metadata +14 -2
@@ -0,0 +1,714 @@
1
+ # Kanban `drop_interaction` Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Let a kanban column run an authorization-aware, input-collecting Interaction when a card is dropped into it — opening the interaction's modal form (e.g. "reason") and committing the move + interaction atomically — while leaving `on_drop` intact for membership + quick-add seeding.
6
+
7
+ **Architecture:** Add a new `drop_interaction:` column option that references an Interaction class. On a drop into such a column the Stimulus controller opens the interaction's modal (holding the card in a pending state) instead of firing the fire-and-forget move POST. The modal form submits back to `kanban_move`, which — in a single transaction — authorizes the interaction's own policy method, runs the interaction, then runs `on_drop` and repositions. Interaction failure rolls the transaction back and re-renders the modal with errors; cancel snaps the card back. `on_drop` keeps its existing role: membership write on plain columns and the quick-add seed source everywhere.
8
+
9
+ **Tech Stack:** Ruby / Rails (Plutonium engine), Phlex + ERB modal views, Hotwired Turbo Streams, Stimulus (`src/js/controllers/kanban_controller.js`), esbuild (`yarn build`), Minitest (`bundle exec appraisal rails-8.1 rake test`).
10
+
11
+ **User Verification:** NO — no user sign-off required; the original request is a feasibility-and-design question turned implementation. Verification is via automated tests + a manual dri(dummy app) smoke described in the final task.
12
+
13
+ ---
14
+
15
+ ## Design contract (read once before Task 0)
16
+
17
+ - **`drop_interaction:` takes an Interaction class**, e.g. `drop_interaction: MarkLostInteraction`. It is a **record action** (its interaction declares `attribute :resource`).
18
+ - **Registration:** at `kanban` compile time each column's `drop_interaction` is registered as a hidden interactive record action, keyed by the interaction's conventional name (`MarkLostInteraction → :mark_lost`). This reuses the existing action/policy/form machinery, so the authorization gate is the natural `def mark_lost?` on the policy — layered on top of the board-wide `kanban_move?`.
19
+ - **Two flows, split cleanly:**
20
+ - **Move flow (drag):** `drop_interaction` opens the modal, then commits `on_drop` + interaction + reposition atomically.
21
+ - **Seed flow (`+ Add` quick-add):** unchanged — always uses `on_drop`'s dry-run (`kanban_column_on_drop_seed`). The interaction is never dry-run.
22
+ - **Commit order inside the move transaction:** `on_drop` (assign + save membership) → `interaction.call` (sees the updated record; persists extras like `reason`; `deliver_later` side-effects only fire post-commit) → `reposition!`. Any failure raises `ActiveRecord::Rollback`.
23
+ - **Contract for authors:** the interaction owns the *extras* (reason, mail, audit); `on_drop` owns the *membership attribute* (status). If the interaction also sets the membership attribute it must be to the same value `on_drop` set (idempotent) — document, don't enforce.
24
+ - **Failure surfaces:** move-guard rejections (`accepts`/`locked`/`wip`/`kanban_move?`) keep the existing `render_kanban_rejection` snap-back-toast path and are checked *before* the modal opens. Interaction failures re-render the modal at 422 with `@interaction.errors`.
25
+
26
+ ### File map
27
+
28
+ | File | Responsibility | Task |
29
+ |---|---|---|
30
+ | `lib/plutonium/kanban/column.rb` | Store + expose `drop_interaction`; derive its action key | 0 |
31
+ | `lib/plutonium/kanban/dsl.rb` | (no change — `**opts` already forwards; verify only) | 0 |
32
+ | `lib/plutonium/definition/index_views.rb` | Register each column's `drop_interaction` as a hidden record action at compile time | 1 |
33
+ | `lib/plutonium/action/base.rb` (+ factory) | Carry a `kanban_drop:` flag so drop actions don't render in toolbars | 1 |
34
+ | `lib/plutonium/routing/mapper_extensions.rb` | Add `GET kanban_move_form` member route | 2 |
35
+ | `lib/plutonium/resource/controllers/kanban_actions.rb` | `kanban_move_form` GET (render modal); `kanban_move` POST interaction branch | 3, 4 |
36
+ | `app/views/**/kanban_move_form` (ERB) | Modal wrapper rendering the interaction form, posting to `kanban_move` | 3 |
37
+ | `lib/plutonium/ui/kanban/column.rb` | Emit `data-kanban-drop-*` on drop-interaction columns | 5 |
38
+ | `src/js/controllers/kanban_controller.js` | On drop into a drop-interaction column, open the modal + pending/snap-back | 6 |
39
+ | `docs/guides/kanban.md`, `docs/reference/kanban/dsl.md`, `.claude/skills/plutonium-kanban/SKILL.md` | Document `drop_interaction` | 7 |
40
+ | `test/dummy/app/definitions/*` + system test | End-to-end drive | 8 |
41
+
42
+ ---
43
+
44
+ ### Task 0: `Column#drop_interaction` + derived action key
45
+
46
+ **Goal:** `Column` accepts and exposes `drop_interaction:`, derives its conventional action key, and rejects a non-interaction value.
47
+
48
+ **Files:**
49
+ - Modify: `lib/plutonium/kanban/column.rb:15-31`
50
+ - Verify (no edit): `lib/plutonium/kanban/dsl.rb:23-27`
51
+ - Test: `test/plutonium/kanban/column_test.rb`
52
+
53
+ **Acceptance Criteria:**
54
+ - [ ] `Column.new(:lost, drop_interaction: MarkLostInteraction).drop_interaction` returns the class.
55
+ - [ ] `#drop_interaction?` is true only when one is set.
56
+ - [ ] `#drop_interaction_key` returns `:mark_lost` for `MarkLostInteraction`.
57
+ - [ ] `Column.new(:x, drop_interaction: "nope")` raises `ArgumentError`.
58
+
59
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb` → all pass.
60
+
61
+ **Steps:**
62
+
63
+ - [ ] **Step 1: Write the failing test**
64
+
65
+ ```ruby
66
+ # test/plutonium/kanban/column_test.rb (add these cases)
67
+ require "test_helper"
68
+
69
+ class DummyDropInteraction < Plutonium::Resource::Interaction
70
+ attribute :resource
71
+ def execute = succeed(resource)
72
+ end
73
+
74
+ class Plutonium::Kanban::ColumnDropInteractionTest < ActiveSupport::TestCase
75
+ test "stores and exposes drop_interaction" do
76
+ col = Plutonium::Kanban::Column.new(:lost, drop_interaction: DummyDropInteraction)
77
+ assert_equal DummyDropInteraction, col.drop_interaction
78
+ assert col.drop_interaction?
79
+ end
80
+
81
+ test "derives conventional action key from the interaction class name" do
82
+ col = Plutonium::Kanban::Column.new(:lost, drop_interaction: DummyDropInteraction)
83
+ assert_equal :dummy_drop, col.drop_interaction_key
84
+ end
85
+
86
+ test "no drop_interaction by default" do
87
+ refute Plutonium::Kanban::Column.new(:todo).drop_interaction?
88
+ end
89
+
90
+ test "rejects a non-interaction drop_interaction" do
91
+ assert_raises(ArgumentError) do
92
+ Plutonium::Kanban::Column.new(:x, drop_interaction: "nope")
93
+ end
94
+ end
95
+ end
96
+ ```
97
+
98
+ - [ ] **Step 2: Run test to verify it fails**
99
+
100
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb`
101
+ Expected: FAIL — `unknown keyword: :drop_interaction`.
102
+
103
+ - [ ] **Step 3: Implement in Column**
104
+
105
+ ```ruby
106
+ # lib/plutonium/kanban/column.rb
107
+ attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions, :drop_interaction
108
+
109
+ def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
110
+ collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil, drop_interaction: nil)
111
+ # ... existing assignments unchanged ...
112
+ @on_drop = on_drop
113
+ if drop_interaction && !(drop_interaction.is_a?(Class) && drop_interaction < Plutonium::Resource::Interaction)
114
+ raise ArgumentError, "drop_interaction: must be a Plutonium::Resource::Interaction subclass, got #{drop_interaction.inspect}"
115
+ end
116
+ @drop_interaction = drop_interaction
117
+ # ... rest unchanged ...
118
+ end
119
+
120
+ def drop_interaction? = !@drop_interaction.nil?
121
+
122
+ # MarkLostInteraction → :mark_lost (strip trailing "Interaction", underscore).
123
+ def drop_interaction_key
124
+ return nil unless @drop_interaction
125
+ @drop_interaction.name.demodulize.sub(/Interaction\z/, "").underscore.to_sym
126
+ end
127
+ ```
128
+
129
+ - [ ] **Step 4: Run test to verify it passes**
130
+
131
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb`
132
+ Expected: PASS.
133
+
134
+ - [ ] **Step 5: Confirm DSL passthrough needs no change**
135
+
136
+ `lib/plutonium/kanban/dsl.rb:23` is `def column(key, **opts, &blk) = Column.new(key, **opts)` — `drop_interaction:` flows through `**opts` untouched. No edit; just confirm by reading.
137
+
138
+ - [ ] **Step 6: Commit**
139
+
140
+ ```bash
141
+ git add lib/plutonium/kanban/column.rb test/plutonium/kanban/column_test.rb
142
+ git commit -m "feat(kanban): add drop_interaction column option"
143
+ ```
144
+
145
+ ---
146
+
147
+ ### Task 1: Register `drop_interaction` as a hidden record action at compile time
148
+
149
+ **Goal:** Each static column's `drop_interaction` is registered as an interactive **record** action (so its policy method + form + params machinery exist) but flagged `kanban_drop: true` so it never renders in a toolbar/row/show.
150
+
151
+ **Files:**
152
+ - Modify: `lib/plutonium/definition/index_views.rb:125-135`
153
+ - Modify: `lib/plutonium/action/base.rb` (add `kanban_drop?` reader + accept the option)
154
+ - Modify: `lib/plutonium/action/interactive/factory.rb` (pass `kanban_drop:` through) — confirm exact path first with `grep -rn "class Factory" lib/plutonium/action`
155
+ - Test: `test/plutonium/definition/index_views_test.rb` (or nearest existing kanban-registration test)
156
+
157
+ **Acceptance Criteria:**
158
+ - [ ] After a definition with `column :lost, drop_interaction: MarkLostInteraction` loads, `definition.defined_actions[:mark_lost]` is an `Action::Interactive` with `record_action? == true`.
159
+ - [ ] That action reports `kanban_drop? == true`.
160
+ - [ ] Actions the toolbar renders exclude `kanban_drop?` actions (assert the filter helper skips it).
161
+ - [ ] Dynamic `columns do…end` boards do **not** auto-register (documented constraint) — no crash, just nothing registered.
162
+
163
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/index_views_test.rb`
164
+
165
+ **Steps:**
166
+
167
+ - [ ] **Step 1: Write the failing test**
168
+
169
+ ```ruby
170
+ # test/plutonium/definition/index_views_test.rb (new case)
171
+ test "registers drop_interaction as a hidden record action" do
172
+ klass = Class.new(Plutonium::Resource::Definition) do
173
+ kanban do
174
+ column :lost, scope: -> { all }, on_drop: ->(r) { r.status = "lost" },
175
+ drop_interaction: MarkLostInteraction
176
+ end
177
+ end
178
+ action = klass.new.defined_actions[:mark_lost]
179
+ assert_kind_of Plutonium::Action::Interactive, action
180
+ assert action.record_action?
181
+ assert action.kanban_drop?
182
+ end
183
+ ```
184
+
185
+ Ensure a `MarkLostInteraction` test fixture exists (define in the test or `test/dummy/app/interactions/mark_lost_interaction.rb` with `attribute :resource`, `attribute :reason, :string`, `input :reason`, `validates :reason, presence: true`, `execute { resource.update!(status: "lost", lost_reason: reason); succeed(resource) }`).
186
+
187
+ - [ ] **Step 2: Run test to verify it fails**
188
+
189
+ Run the file. Expected: FAIL — `:mark_lost` not registered / `kanban_drop?` undefined.
190
+
191
+ - [ ] **Step 3: Add the `kanban_drop` flag to the Action base**
192
+
193
+ ```ruby
194
+ # lib/plutonium/action/base.rb — in initialize, accept and store the flag
195
+ # alongside record_action/collection_record_action:
196
+ @kanban_drop = opts.fetch(:kanban_drop, false) # match the surrounding option-reading style
197
+ # and add the reader near record_action?:
198
+ def kanban_drop? = @kanban_drop
199
+ ```
200
+
201
+ Thread `kanban_drop:` through `Action::Interactive::Factory.create` (it already splats `**opts` to the action — confirm and, if it whitelists keys, add `:kanban_drop`).
202
+
203
+ - [ ] **Step 4: Register drop_interactions in the kanban compile block**
204
+
205
+ ```ruby
206
+ # lib/plutonium/definition/index_views.rb — inside kanban(&block),
207
+ # after the existing `board.columns.each { |col| col.actions.each … }` loop:
208
+ board.columns.each do |col|
209
+ next unless col.drop_interaction?
210
+ action(
211
+ col.drop_interaction_key,
212
+ interaction: col.drop_interaction,
213
+ record_action: true,
214
+ kanban_drop: true
215
+ )
216
+ end
217
+ ```
218
+
219
+ - [ ] **Step 5: Exclude `kanban_drop?` actions from toolbars**
220
+
221
+ Find where record/collection actions are filtered for display (grep `record_action?` / `collection_record_action?` in `lib/plutonium/ui/` and `lib/plutonium/resource/`). Add `&& !action.kanban_drop?` to those display filters. Add an assertion in the test that the show/row action set excludes `:mark_lost`.
222
+
223
+ - [ ] **Step 6: Run tests**
224
+
225
+ Run the index_views test + `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/base_test.rb` (if present). Expected: PASS.
226
+
227
+ - [ ] **Step 7: Commit**
228
+
229
+ ```bash
230
+ git add lib/plutonium/definition/index_views.rb lib/plutonium/action/ test/
231
+ git commit -m "feat(kanban): register drop_interaction as hidden record action"
232
+ ```
233
+
234
+ ---
235
+
236
+ ### Task 2: Add the `GET kanban_move_form` member route
237
+
238
+ **Goal:** A member route that renders the drop interaction's modal form for a pending drop.
239
+
240
+ **Files:**
241
+ - Modify: `lib/plutonium/routing/mapper_extensions.rb:148-155`
242
+ - Test: `test/plutonium/routing/…` (nearest routing test) or assert via an integration test in Task 4.
243
+
244
+ **Acceptance Criteria:**
245
+ - [ ] `GET <member>/kanban_move_form` routes to `kanban_actions#kanban_move_form`, named `kanban_move_form`.
246
+ - [ ] Existing `POST kanban_move` unchanged.
247
+
248
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/dummy_routes_test.rb` (or `rails routes | grep kanban` in the dummy app).
249
+
250
+ **Steps:**
251
+
252
+ - [ ] **Step 1: Add the route**
253
+
254
+ ```ruby
255
+ # lib/plutonium/routing/mapper_extensions.rb — in define_member_interactive_actions,
256
+ # next to the existing kanban_move POST (line 154):
257
+ get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
258
+ post "kanban_move", action: :kanban_move, as: :kanban_move
259
+ ```
260
+
261
+ - [ ] **Step 2: Verify routing**
262
+
263
+ Run in the dummy app: `cd test/dummy && bin/rails routes | grep kanban_move`
264
+ Expected: both `kanban_move_form` (GET) and `kanban_move` (POST) present for kanban resources.
265
+
266
+ - [ ] **Step 3: Commit**
267
+
268
+ ```bash
269
+ git add lib/plutonium/routing/mapper_extensions.rb
270
+ git commit -m "feat(kanban): add kanban_move_form member route"
271
+ ```
272
+
273
+ ---
274
+
275
+ ### Task 3: `kanban_move_form` GET — render the interaction modal posting to `kanban_move`
276
+
277
+ **Goal:** Build the drop interaction as a record action and render its form inside the remote modal, with the form action set to `kanban_move` and the move params (`from_column`, `to_column`, `to_index`) as hidden fields.
278
+
279
+ **Files:**
280
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` (add `kanban_move_form` public action + helpers)
281
+ - Create: `app/views/plutonium/resource/kanban_move_form.html.erb` (confirm the resource views dir with `find app -path '*resource*' -name 'interactive_record_action*'`; co-locate the new template there)
282
+ - Test: covered by Task 4 integration test (GET assertions).
283
+
284
+ **Acceptance Criteria:**
285
+ - [ ] `GET kanban_move_form?to_column=lost&from_column=doing&to_index=0` on a card renders a modal containing the interaction's inputs (e.g. a `reason` field).
286
+ - [ ] The rendered `<form>` posts to the `kanban_move` path and carries hidden `from_column`, `to_column`, `to_index`.
287
+ - [ ] A column with no `drop_interaction` → `head :unprocessable_content` (the client should never call it for such columns, but guard anyway).
288
+ - [ ] Authorization: the request runs `authorize_current! record, to: :<drop_interaction_key>?` and 403s when denied.
289
+
290
+ **Verify:** Task 4's integration test `test_kanban_move_form_renders_interaction_modal`.
291
+
292
+ **Steps:**
293
+
294
+ - [ ] **Step 1: Add `kanban_move_form` to KanbanActions**
295
+
296
+ ```ruby
297
+ # lib/plutonium/resource/controllers/kanban_actions.rb
298
+ # GET <member>/kanban_move_form?from_column=&to_column=&to_index=
299
+ def kanban_move_form
300
+ record = kanban_base_relation.find(params[:id])
301
+ to = kanban_column_for(params[:to_column])
302
+ unless to&.drop_interaction?
303
+ head :unprocessable_content
304
+ return
305
+ end
306
+ # Authorize the specific transition (layered on kanban_move? at commit time).
307
+ authorize_current! record, to: :"#{to.drop_interaction_key}?"
308
+
309
+ @interaction = to.drop_interaction.new(view_context:)
310
+ @interaction.resource = record
311
+ @kanban_move_params = {
312
+ from_column: params[:from_column],
313
+ to_column: params[:to_column],
314
+ to_index: params[:to_index]
315
+ }
316
+ render :kanban_move_form, formats: [:html], **modal_render_options
317
+ end
318
+
319
+ private
320
+
321
+ # Resolve a column by key string against the current board (shared by
322
+ # kanban_move_form and kanban_move).
323
+ def kanban_column_for(key)
324
+ columns = Plutonium::Kanban::Grouping.resolve_columns(current_kanban_board, kanban_context)
325
+ columns.find { |c| c.key.to_s == key.to_s }
326
+ end
327
+ ```
328
+
329
+ `modal_render_options` and `authorize_current!` are already available (the controller includes `InteractiveActions`). Confirm `KanbanActions` is included after `InteractiveActions`, or reference `Plutonium::REMOTE_MODAL_FRAME` chrome directly in the view.
330
+
331
+ - [ ] **Step 2: Create the modal view**
332
+
333
+ ```erb
334
+ <%# app/views/plutonium/resource/kanban_move_form.html.erb %>
335
+ <%# Mirrors interactive_record_action.html.erb chrome, but posts to kanban_move
336
+ and carries the move params so the commit is a single atomic request. %>
337
+ <%= turbo_frame_tag Plutonium::REMOTE_MODAL_FRAME do %>
338
+ <%= render "plutonium/modal", title: @interaction.class.label do %>
339
+ <%= form_with url: resource_url_for(resource_record!, action: :kanban_move),
340
+ method: :post,
341
+ data: { turbo_frame: "_top" } do |f| %>
342
+ <% @kanban_move_params.each do |k, v| %>
343
+ <%= f.hidden_field k, value: v %>
344
+ <% end %>
345
+ <%= render @interaction.build_form %>
346
+ <%= f.submit @interaction.class.label %>
347
+ <% end %>
348
+ <% end %>
349
+ <% end %>
350
+ ```
351
+
352
+ Confirm the exact modal partial + form component used by `interactive_record_action.html.erb` (read it first) and match it — reuse the same `@interaction.build_form` and submit styling so this modal is visually identical to a normal record-action modal. Confirm `resource_url_for(record, action: :kanban_move)` produces the member `kanban_move` path; if not, build it via the named route helper `resource_url_for` supports.
353
+
354
+ - [ ] **Step 3: Commit**
355
+
356
+ ```bash
357
+ git add lib/plutonium/resource/controllers/kanban_actions.rb app/views/plutonium/resource/kanban_move_form.html.erb
358
+ git commit -m "feat(kanban): render drop interaction modal via kanban_move_form"
359
+ ```
360
+
361
+ ---
362
+
363
+ ### Task 4: `kanban_move` POST — atomic interaction + on_drop + reposition
364
+
365
+ **Goal:** When the destination column has a `drop_interaction`, commit the interaction and the move in one transaction: authorize the transition, run `on_drop` (membership), run the interaction (extras), reposition. Interaction failure rolls back and re-renders the modal at 422; success returns column Turbo Streams (which also closes the modal).
366
+
367
+ **Files:**
368
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb:57-167`
369
+ - Test: `test/integration/**/kanban_drop_interaction_test.rb` (new)
370
+
371
+ **Acceptance Criteria:**
372
+ - [ ] Dropping onto a drop-interaction column with valid input persists `on_drop`'s membership change, the interaction's extras (e.g. `lost_reason`), and repositions — all committed together.
373
+ - [ ] Interaction validation failure (blank `reason`): nothing persists (membership unchanged), response is 422 re-rendering the modal with the error.
374
+ - [ ] Transition authorization: `authorize_current! record, to: :<key>?` runs; denial → 403 and no writes.
375
+ - [ ] Move guards (`accepts`/`locked`/`wip`) are checked before the interaction runs and still snap-back-toast via `render_kanban_rejection`.
376
+ - [ ] Plain columns (no `drop_interaction`) behave exactly as before (existing kanban_move tests still pass).
377
+ - [ ] Quick-add seed path (`kanban_column_on_drop_seed`) is unaffected — still uses `on_drop`.
378
+
379
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/**/kanban_drop_interaction_test.rb` and the existing kanban move test file both green.
380
+
381
+ **Steps:**
382
+
383
+ - [ ] **Step 1: Write the failing integration test**
384
+
385
+ ```ruby
386
+ # test/integration/<portal>/kanban_drop_interaction_test.rb
387
+ require "test_helper"
388
+
389
+ class KanbanDropInteractionTest < ActionDispatch::IntegrationTest
390
+ # setup: sign in, seed a card in the "doing" column of a resource whose
391
+ # definition declares column :lost, drop_interaction: MarkLostInteraction.
392
+
393
+ test "commits interaction extras + membership + position atomically" do
394
+ post kanban_move_path(card),
395
+ params: { from_column: "doing", to_column: "lost", to_index: 0,
396
+ interaction: { reason: "budget cut" } },
397
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
398
+ assert_response :success
399
+ card.reload
400
+ assert_equal "lost", card.status
401
+ assert_equal "budget cut", card.lost_reason
402
+ end
403
+
404
+ test "blank reason rolls back and re-renders modal at 422" do
405
+ post kanban_move_path(card),
406
+ params: { from_column: "doing", to_column: "lost", to_index: 0,
407
+ interaction: { reason: "" } },
408
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
409
+ assert_response :unprocessable_content
410
+ card.reload
411
+ assert_equal "doing", card.status # membership NOT changed
412
+ assert_nil card.lost_reason
413
+ assert_match "can't be blank", @response.body
414
+ end
415
+ end
416
+ ```
417
+
418
+ - [ ] **Step 2: Run to verify it fails**
419
+
420
+ Expected: FAIL — the interaction never runs (current `kanban_move` ignores `drop_interaction`); reason not persisted, no 422 modal.
421
+
422
+ - [ ] **Step 3: Branch `kanban_move` on `drop_interaction`**
423
+
424
+ Refactor the existing transaction (`kanban_actions.rb:111-144`) so the interaction runs inside it. After the existing guard checks (accepts/locked/wip stay unchanged and BEFORE this block), add:
425
+
426
+ ```ruby
427
+ outcome = nil
428
+ ActiveRecord::Base.transaction do
429
+ # 1. Membership write (unchanged on_drop dispatch).
430
+ if to.on_drop.is_a?(Symbol)
431
+ record.public_send(to.on_drop)
432
+ elsif to.on_drop
433
+ kanban_context.instance_exec(record, &to.on_drop)
434
+ end
435
+ record.save! if record.changed?
436
+
437
+ # 2. Drop interaction (input + authz + errors), same record instance.
438
+ if to.drop_interaction?
439
+ authorize_current! record, to: :"#{to.drop_interaction_key}?"
440
+ interaction = to.drop_interaction.new(view_context:)
441
+ interaction.attributes = kanban_interaction_params(to).merge(resource: record)
442
+ outcome = interaction.call
443
+ if outcome.failure?
444
+ @interaction = interaction
445
+ @kanban_move_params = params.slice(:from_column, :to_column, :to_index).to_unsafe_h
446
+ raise ActiveRecord::Rollback
447
+ end
448
+ end
449
+
450
+ # 3. Reposition (unchanged).
451
+ board.position_config.reposition!(
452
+ record:, column: to.key, prev_record:, next_record:, index: to_index
453
+ )
454
+ record.save! if record.changed?
455
+ end
456
+
457
+ # Interaction failed → re-render the modal with errors (transaction rolled back).
458
+ if to.drop_interaction? && outcome&.failure?
459
+ return render :kanban_move_form, formats: [:html], **modal_render_options,
460
+ status: :unprocessable_content
461
+ end
462
+ ```
463
+
464
+ Add the params helper (reuses the interactive-action extraction so structured inputs / choices work identically):
465
+
466
+ ```ruby
467
+ # Extract the submitted interaction params for a drop interaction. Mirrors
468
+ # InteractiveActions#interaction_params but keyed off the column's interaction.
469
+ def kanban_interaction_params(column)
470
+ action_key = column.drop_interaction_key
471
+ params[:interaction] ? params.require(:interaction).permit!.to_h : {}
472
+ # If structured inputs are used, swap this for the shared extract_input
473
+ # pipeline keyed on column.drop_interaction (see submitted_interaction_params).
474
+ end
475
+ ```
476
+
477
+ > Prefer routing through the existing `submitted_interaction_params` machinery if the interaction uses `structured_input`/`choices:` — read `interactive_actions.rb:244-280` and reuse `build_form(instance).extract_input(...)` rather than a naive `permit!`. Keep the naive form only if the drop interactions in scope use plain scalar inputs.
478
+
479
+ - [ ] **Step 4: Keep the success response as-is**
480
+
481
+ The existing `respond_to { format.turbo_stream { … column updates … } }` block already re-renders the from/to column frames. Because the modal form posts with `data-turbo-frame="_top"`, the returned Turbo Streams replace the columns and the modal frame is left empty/closed by the stream. Confirm the modal actually dismisses on success in Task 8's manual drive; if it lingers, append `turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")` to the success streams.
482
+
483
+ - [ ] **Step 5: Run tests**
484
+
485
+ Run the new integration test AND the existing kanban move test file. Expected: both PASS.
486
+
487
+ - [ ] **Step 6: Commit**
488
+
489
+ ```bash
490
+ git add lib/plutonium/resource/controllers/kanban_actions.rb test/integration/
491
+ git commit -m "feat(kanban): commit drop interaction + move atomically in kanban_move"
492
+ ```
493
+
494
+ ---
495
+
496
+ ### Task 5: Emit `data-kanban-drop-*` on drop-interaction columns
497
+
498
+ **Goal:** The column component advertises, in the DOM, that a column requires the interaction modal on drop, and provides the form URL template.
499
+
500
+ **Files:**
501
+ - Modify: `lib/plutonium/ui/kanban/column.rb`
502
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` `render_kanban_column_html` (pass the form URL template into the component)
503
+ - Test: `test/integration/**/kanban_dom_contract_test.rb` (the file the user has open — extend it)
504
+
505
+ **Acceptance Criteria:**
506
+ - [ ] A column with `drop_interaction` renders `data-kanban-drop-interaction="true"` on its `[data-kanban-col]` wrapper.
507
+ - [ ] It also renders `data-kanban-drop-form-url-template` = the `kanban_move_form` path with an `__ID__` placeholder (mirroring the existing `move-url-template`).
508
+ - [ ] Plain columns render neither attribute.
509
+
510
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/**/kanban_dom_contract_test.rb`
511
+
512
+ **Steps:**
513
+
514
+ - [ ] **Step 1: Extend the DOM-contract test**
515
+
516
+ ```ruby
517
+ test "drop-interaction column advertises the modal drop contract" do
518
+ # render a board whose :lost column has drop_interaction
519
+ assert_select "[data-kanban-col='lost'][data-kanban-drop-interaction='true']"
520
+ assert_select "[data-kanban-col='lost'][data-kanban-drop-form-url-template*='__ID__']"
521
+ assert_select "[data-kanban-col='todo']:not([data-kanban-drop-interaction])"
522
+ end
523
+ ```
524
+
525
+ - [ ] **Step 2: Pass the form URL template into the component**
526
+
527
+ In `render_kanban_column_html` (`kanban_actions.rb:301-313`), compute and pass:
528
+
529
+ ```ruby
530
+ drop_form_url_template: (column.drop_interaction? ?
531
+ resource_url_for(resource_class, action: :kanban_move_form).sub("kanban_move_form", "__ID__/kanban_move_form") :
532
+ nil),
533
+ ```
534
+
535
+ Verify the exact shape of the member form URL and build the `__ID__` template the same way the board builds `move-url-template` (grep `move_url_template` / `__ID__` in `lib/plutonium/ui/kanban/`).
536
+
537
+ - [ ] **Step 3: Render the attributes in the column component**
538
+
539
+ ```ruby
540
+ # lib/plutonium/ui/kanban/column.rb — on the [data-kanban-col] wrapper div,
541
+ # add to its attribute hash:
542
+ data: {
543
+ kanban_col: column.key,
544
+ # ...existing accepts/locked/default-collapsed...
545
+ **(column.drop_interaction? ? {
546
+ kanban_drop_interaction: "true",
547
+ kanban_drop_form_url_template: @drop_form_url_template
548
+ } : {})
549
+ }
550
+ ```
551
+
552
+ Add the `drop_form_url_template:` keyword to the component's `initialize` and store `@drop_form_url_template`.
553
+
554
+ - [ ] **Step 4: Run the test**
555
+
556
+ Expected: PASS.
557
+
558
+ - [ ] **Step 5: Commit**
559
+
560
+ ```bash
561
+ git add lib/plutonium/ui/kanban/column.rb lib/plutonium/resource/controllers/kanban_actions.rb test/
562
+ git commit -m "feat(kanban): advertise drop interaction contract in column DOM"
563
+ ```
564
+
565
+ ---
566
+
567
+ ### Task 6: Stimulus — open the modal on drop, hold pending, snap back on cancel
568
+
569
+ **Goal:** On a drop into a `data-kanban-drop-interaction` column, the controller navigates the remote-modal frame to the `kanban_move_form` URL (carrying the move params) instead of firing the move POST; the card stays visually pending; canceling/closing the modal without success snaps it back.
570
+
571
+ **Files:**
572
+ - Modify: `src/js/controllers/kanban_controller.js:430-487` (`#onDrop`)
573
+ - Build: `yarn build` → regenerates `app/assets/plutonium*.js`
574
+ - Test: system test in Task 8 (JS behavior is driven there).
575
+
576
+ **Acceptance Criteria:**
577
+ - [ ] Dropping into a drop-interaction column opens the modal (frame `src` set to the form URL with `from_column`/`to_column`/`to_index` in the query), no move POST fires yet.
578
+ - [ ] Successful modal submit → columns re-render via the returned streams (existing path), pending state cleared.
579
+ - [ ] Modal dismissed without success → source column reloaded (snap-back), pending state cleared.
580
+ - [ ] Plain columns keep the existing direct-POST behavior byte-for-byte.
581
+
582
+ **Verify:** `yarn build` succeeds; Task 8 system test drives it.
583
+
584
+ **Steps:**
585
+
586
+ - [ ] **Step 1: Branch `#onDrop` on the drop-interaction contract**
587
+
588
+ ```js
589
+ // src/js/controllers/kanban_controller.js — inside #onDrop, after computing
590
+ // recordId / fromColumn / toColumn / toIndex, before the fetch():
591
+ const colWrapper = column.closest("[data-kanban-col]")
592
+ if (colWrapper?.dataset.kanbanDropInteraction === "true") {
593
+ return this.#openDropInteraction(colWrapper, { recordId, fromColumn, toColumn, toIndex })
594
+ }
595
+ // ...existing direct fetch(url, POST { from_column, to_column, to_index }) unchanged...
596
+ ```
597
+
598
+ - [ ] **Step 2: Add the modal-opening + pending/snap-back handler**
599
+
600
+ ```js
601
+ // Opens the drop interaction's modal by pointing the remote-modal frame at the
602
+ // kanban_move_form URL. The card is left where the user dropped it (pending);
603
+ // the modal's Turbo-Stream response re-renders the columns on success. If the
604
+ // modal closes without a successful commit, reload the source column to snap
605
+ // the card back.
606
+ #openDropInteraction(colWrapper, { recordId, fromColumn, toColumn, toIndex }) {
607
+ const tmpl = colWrapper.dataset.kanbanDropFormUrlTemplate
608
+ const params = new URLSearchParams({ from_column: fromColumn, to_column: toColumn, to_index: toIndex })
609
+ const src = `${tmpl.replace("__ID__", recordId)}?${params.toString()}`
610
+
611
+ const frame = document.getElementById("remote_modal") // confirm Plutonium::REMOTE_MODAL_FRAME id
612
+ if (!frame) return
613
+ this.pendingSnapBackColumn = fromColumn
614
+
615
+ // If the modal frame empties (closed) without a move stream having landed,
616
+ // snap the source column back.
617
+ const onFrameLoad = () => {
618
+ if (!frame.innerHTML.trim() && this.pendingSnapBackColumn) this.#reloadColumn(this.pendingSnapBackColumn)
619
+ }
620
+ frame.addEventListener("turbo:frame-load", onFrameLoad, { once: true })
621
+ frame.src = src
622
+ }
623
+
624
+ #reloadColumn(key) {
625
+ const frame = this.element.querySelector(`turbo-frame[data-kanban-col-frame='${key}']`)
626
+ if (frame) frame.src = this.#columnFrameSrc(key)
627
+ this.pendingSnapBackColumn = null
628
+ }
629
+ ```
630
+
631
+ Clear `pendingSnapBackColumn` in `#onBeforeStreamRender` (a successful move stream landed → no snap-back needed). Confirm the actual remote-modal frame id/selector from `Plutonium::REMOTE_MODAL_FRAME` and the layout; adjust `getElementById` accordingly. Keep this handler minimal — the server owns all state; the controller only decides *reload source column* vs *let the move stream render*.
632
+
633
+ - [ ] **Step 3: Build assets**
634
+
635
+ Run: `yarn build`
636
+ Expected: `app/assets/plutonium.js` (+ min + map) regenerated, no build errors.
637
+
638
+ - [ ] **Step 4: Commit**
639
+
640
+ ```bash
641
+ git add src/js/controllers/kanban_controller.js app/assets/plutonium*.js app/assets/plutonium*.map
642
+ git commit -m "feat(kanban): open drop interaction modal with pending/snap-back"
643
+ ```
644
+
645
+ ---
646
+
647
+ ### Task 7: Documentation + skill
648
+
649
+ **Goal:** Document `drop_interaction` in the guide, DSL reference, and the plutonium-kanban skill, including the two-flow model and the author contract.
650
+
651
+ **Files:**
652
+ - Modify: `docs/guides/kanban.md`
653
+ - Modify: `docs/reference/kanban/dsl.md`
654
+ - Modify: `.claude/skills/plutonium-kanban/SKILL.md`
655
+
656
+ **Acceptance Criteria:**
657
+ - [ ] Guide has a "Interaction on drop" section with a `column :lost, on_drop:…, drop_interaction: MarkLostInteraction` example and the `MarkLostInteraction` body.
658
+ - [ ] The `on_drop:` vs `drop_interaction:` split (move flow vs seed flow), the author contract (interaction owns extras; on_drop owns membership), and the `def mark_lost?` authorization gate are all stated.
659
+ - [ ] DSL reference table lists `drop_interaction:` under Column options.
660
+ - [ ] Skill `Column options` block + Authorization section mention it.
661
+ - [ ] `yarn docs:build` passes (no broken links).
662
+
663
+ **Verify:** `yarn docs:build`
664
+
665
+ **Steps:**
666
+
667
+ - [ ] **Step 1: Write the guide section, DSL row, and skill edits** (prose — mirror the Design contract above). Include the worked example and the `MarkLostInteraction` class.
668
+ - [ ] **Step 2: Build docs** — `yarn docs:build`, fix any broken links.
669
+ - [ ] **Step 3: Commit**
670
+
671
+ ```bash
672
+ git add docs/ .claude/skills/plutonium-kanban/SKILL.md
673
+ git commit -m "docs(kanban): document drop_interaction"
674
+ ```
675
+
676
+ ---
677
+
678
+ ### Task 8: End-to-end drive in the dummy app + system test
679
+
680
+ **Goal:** Prove the whole flow (drag → modal → reason → atomic commit; blank reason → 422 modal; cancel → snap-back) in the real dummy app and lock it with a system test.
681
+
682
+ **Files:**
683
+ - Modify (via generators only — see memory `feedback_always_use_generators`): a dummy resource + interaction, OR reuse `test/dummy/app/definitions/task_definition.rb` by adding a `:lost` column with `drop_interaction`. Add `lost_reason` via a migration (inline index per CLAUDE.md).
684
+ - Create: `test/system/**/kanban_drop_interaction_test.rb`
685
+
686
+ **Acceptance Criteria:**
687
+ - [ ] System test: drag a card to `:lost`, modal opens, submit with reason → card lands in `:lost`, `lost_reason` persisted, modal closed.
688
+ - [ ] System test: submit with blank reason → modal shows error, card not moved.
689
+ - [ ] System test: open modal, cancel → card snaps back to source column.
690
+ - [ ] Manual drive (see memory `reference_driving_dummy_app_browser`): confirm the same three flows in a browser against `test/dummy`.
691
+
692
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/system/**/kanban_drop_interaction_test.rb` (headless), plus a manual browser drive.
693
+
694
+ **Steps:**
695
+
696
+ - [ ] **Step 1: Add the dummy fixture** — migration for `lost_reason` (inline any index in `create_table`/`change_table`), a `MarkLostInteraction`, and the `:lost` column with `drop_interaction:` on the task definition. Add `def mark_lost? = update?` to the task policy.
697
+ - [ ] **Step 2: Write the system test** covering the three flows above.
698
+ - [ ] **Step 3: Run it headless.** Expected: PASS.
699
+ - [ ] **Step 4: Manual browser drive** per the memory note (seed test DB, login alice/password123), confirm modal open/commit/cancel visually.
700
+ - [ ] **Step 5: Commit**
701
+
702
+ ```bash
703
+ git add test/ test/dummy/
704
+ git commit -m "test(kanban): system + dummy coverage for drop_interaction"
705
+ ```
706
+
707
+ ---
708
+
709
+ ## Self-Review notes
710
+
711
+ - **Spec coverage:** DSL option (T0), registration/authz (T1), routing (T2), modal GET (T3), atomic commit + failure 422 (T4), DOM contract (T5), Stimulus pending/snap-back (T6), docs (T7), e2e (T8). All design-contract points map to a task.
712
+ - **Type consistency:** `drop_interaction_key` derives `:mark_lost`; used identically in T1 registration, T3/T4 authorization (`:"#{key}?"`), and the policy method `def mark_lost?`. `data-kanban-drop-interaction` / `data-kanban-drop-form-url-template` names match between T5 (emit) and T6 (read).
713
+ - **Open confirmations flagged inline** (not placeholders — real "verify exact path" steps): the interactive-action view/modal partial to mirror (T3), the `Action::Interactive::Factory` signature (T1), the remote-modal frame id (T6), and whether to route params through `submitted_interaction_params` for structured inputs (T4).
714
+ - **Verification requirement scan:** the original prompt requires NO user sign-off → no `requiresUserVerification` task needed.