plutonium 0.60.5 → 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 (199) 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 +378 -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 +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  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/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. metadata +113 -2
@@ -0,0 +1,557 @@
1
+ # Kanban Boards
2
+
3
+ ::: warning Experimental
4
+ Kanban boards are experimental — the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ Turn any resource index into a drag-and-drop kanban board — columns, WIP limits, quick-add, column actions, and opt-in realtime — all from a single `kanban do…end` block in your definition.
8
+
9
+ ![A kanban board grouped by status — cards with badges, a WIP badge on Pending, a quick-add button, and collapsible columns](/images/guides/kanban-board.png)
10
+
11
+ ## What you get
12
+
13
+ - Drag cards between columns; the server persists the column change and the position within the column.
14
+ - Decimal fractional positioning — cards always land exactly where you drop them without renumbering.
15
+ - Per-column `+ Add` button opens the resource's normal new form; the new card is placed in that column (`on_enter` + positioning applied post-create).
16
+ - Column actions run an interaction against all (or visible) cards in a column.
17
+ - WIP limits, locked columns, and cross-column drop restrictions enforced server-side.
18
+ - Opt-in realtime: every connected viewer sees the same board state after any move.
19
+
20
+ ## Worked example — Task board
21
+
22
+ A complete board for a `Task` model grouped by status — migration, model, definition, and policy.
23
+
24
+ ### 1. Migration
25
+
26
+ The model needs a `decimal` position column. Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
27
+
28
+ ```ruby
29
+ class CreateTasks < ActiveRecord::Migration[8.1]
30
+ def change
31
+ create_table :tasks do |t|
32
+ t.string :title, null: false
33
+ t.string :status, null: false, default: "todo"
34
+ t.position # decimal :position, precision: 16, scale: 8
35
+ t.timestamps
36
+
37
+ t.index [:status, :position]
38
+ end
39
+ end
40
+ end
41
+ ```
42
+
43
+ ### 2. Model
44
+
45
+ ```ruby
46
+ class Task < ApplicationRecord
47
+ include Plutonium::Positioning
48
+
49
+ positioned_on :position, scope: :status
50
+ # ^^ auto-assigns position on create; reposition! scopes to the same status
51
+
52
+ validates :status, inclusion: { in: %w[todo doing done] }
53
+
54
+ def mark_done!
55
+ update!(status: "done")
56
+ end
57
+ end
58
+ ```
59
+
60
+ ### 3. Definition
61
+
62
+ ```ruby
63
+ class TaskDefinition < ResourceDefinition
64
+ kanban do
65
+ per_column 25
66
+
67
+ column :todo,
68
+ scope: -> { where(status: "todo") },
69
+ on_enter: ->(r) { r.update!(status: "todo") },
70
+ role: :backlog # shorthand for add: true
71
+
72
+ column :doing,
73
+ scope: -> { where(status: "doing") },
74
+ on_enter: ->(r) { r.update!(status: "doing") },
75
+ wip: 3
76
+
77
+ column :done,
78
+ scope: -> { where(status: "done") },
79
+ on_enter: :mark_done!, # Symbol → record.mark_done!
80
+ accepts: [:doing], # only cards from :doing can land here
81
+ role: :done do # shorthand for color: :green, collapsed: true
82
+ action :archive_all,
83
+ interaction: ArchiveTasksInteraction,
84
+ on: :all,
85
+ label: "Archive all"
86
+ end
87
+ end
88
+ end
89
+ ```
90
+
91
+ ### 4. Policy
92
+
93
+ A move is authorized via `kanban_move?`, which defaults to `update?`. Override it only when you want board-drag access to differ from full-edit access:
94
+
95
+ ```ruby
96
+ class TaskPolicy < ResourcePolicy
97
+ # Allow all authenticated members to move cards,
98
+ # but require :admin to edit the form directly.
99
+ def kanban_move?
100
+ true
101
+ end
102
+ end
103
+ ```
104
+
105
+ ### 5. Routes — no changes needed
106
+
107
+ The `kanban_move` member route is wired automatically when the controller includes `Plutonium::Resource::Controllers::KanbanActions` (included by default in all Plutonium resource controllers).
108
+
109
+ Visit the resource index and use the view switcher to select the Kanban view.
110
+
111
+ ![After dragging a card from Doing to Done — both column frames re-render and the WIP badge on Doing updates in place](/images/guides/kanban-after-move.png)
112
+
113
+ ### When a drop is rejected
114
+
115
+ If a move is refused server-side — the destination is at its `wip:` limit, its `accepts:` policy rejects the card, or the source column is `locked:` — the card snaps back to where it started **and** a dismissable toast explains why:
116
+
117
+ ![A warning toast reading “Pending” is at its WIP limit (5) after a rejected drop](/images/guides/kanban-wip-toast.png)
118
+
119
+ The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check — most commonly a WIP-full column or a `kanban_move?` denial.
120
+
121
+ ### Opening a card
122
+
123
+ Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in) — full-page by default, or a **centered modal** that keeps the board visible behind it:
124
+
125
+ ![A card's show page open in a centered modal over the board, with an expand icon to open the full page](/images/guides/kanban-show-centered-modal.png)
126
+
127
+ ```ruby
128
+ class TaskDefinition < ResourceDefinition
129
+ show_in :modal # open show in a modal everywhere (table, grid, board)
130
+
131
+ kanban do
132
+ # show_in :page # …or override just this board back to full-page
133
+ end
134
+ end
135
+ ```
136
+
137
+ - Set `show_in :modal` on the **definition** to open show in a modal from the table, grid, and board alike. Set it on the **kanban block** to change only the board. An unset board inherits the definition (which defaults to `:page`).
138
+ - The show modal is always **centered** — distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
139
+ - From inside the modal, an expand icon opens the record's full page in a new tab. ⌘/Ctrl-click (or middle-click) on a card does the same directly.
140
+
141
+ ---
142
+
143
+ ## Worked example — Status enum board
144
+
145
+ A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout — no explicit `card_fields` needed.
146
+
147
+ ```ruby
148
+ class KitchenSinkDefinition < ResourceDefinition
149
+ kanban do
150
+ column :active, label: "Active", role: :backlog,
151
+ scope: -> { where(status: :active) },
152
+ on_enter: ->(ks) { ks.status = :active }
153
+
154
+ column :pending, label: "Pending", color: :yellow, wip: 5,
155
+ scope: -> { where(status: :pending) },
156
+ on_enter: ->(ks) { ks.status = :pending }
157
+
158
+ column :archived, label: "Archived", role: :done,
159
+ scope: -> { where(status: :archived) },
160
+ on_enter: ->(ks) { ks.status = :archived }
161
+
162
+ per_column 10
163
+ end
164
+ end
165
+ ```
166
+
167
+ Key points:
168
+ - `role: :backlog` enables the `+ Add` button (equivalent to `add: true`).
169
+ - `wip: 5` caps the Pending column; a cross-column drop that would push it past 5 is rejected server-side.
170
+ - `role: :done` collapses the Archived column by default and shows a green header dot.
171
+ - `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns — you do not need to call `update!` explicitly.
172
+
173
+ ---
174
+
175
+ ## Columns
176
+
177
+ ### Static columns
178
+
179
+ Declared at definition class-load time with `column :key, **opts`:
180
+
181
+ ```ruby
182
+ kanban do
183
+ column :backlog,
184
+ label: "Product Backlog", # default: key.to_s.titleize
185
+ color: :blue, # dot color in the column header
186
+ scope: -> { where(stage: 0) }, # 0-arg lambda evaluated on the relation
187
+ on_enter: ->(r) { r.update!(stage: 0) }
188
+ end
189
+ ```
190
+
191
+ ### Dynamic columns
192
+
193
+ Use `columns do…end` when the column list depends on request context (`current_user`, `params`, etc.):
194
+
195
+ ```ruby
196
+ kanban do
197
+ columns do
198
+ # `self` is the view_context — current_user, params, helpers all work.
199
+ current_user.projects.map do |project|
200
+ Plutonium::Kanban::Column.new(
201
+ :"project_#{project.id}",
202
+ label: project.name,
203
+ scope: -> { where(project_id: project.id) },
204
+ on_enter: ->(r) { r.update!(project_id: project.id) }
205
+ )
206
+ end
207
+ end
208
+ end
209
+ ```
210
+
211
+ ::: warning Dynamic boards and column actions
212
+ Column actions declared inside a `columns do…end` block **cannot be auto-registered** at class-load time (the block is only evaluated at request time). Declare those interaction classes as top-level definition actions separately:
213
+
214
+ ```ruby
215
+ class TaskDefinition < ResourceDefinition
216
+ # Must be a top-level action so the route exists at startup.
217
+ action :archive_project_tasks, interaction: ArchiveProjectTasksInteraction
218
+
219
+ kanban do
220
+ columns do
221
+ current_user.projects.map do |project|
222
+ col = Plutonium::Kanban::Column.new(:"project_#{project.id}", ...)
223
+ col.action :archive_project_tasks, interaction: ArchiveProjectTasksInteraction
224
+ col
225
+ end
226
+ end
227
+ end
228
+ end
229
+ ```
230
+
231
+ Note that **`enter_interaction:` is not supported on dynamic boards** — its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
232
+ :::
233
+
234
+ ### Column options
235
+
236
+ | Option | Type | Default | Description |
237
+ |--------|------|---------|-------------|
238
+ | `label:` | String | `key.to_s.titleize` | Column header text |
239
+ | `color:` | Symbol or String | `nil` | Dot color in the column header — `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
240
+ | `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
241
+ | `on_enter:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
242
+ | `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter:` — called when a card **leaves** this column on a cross-column move, before the destination's `on_enter`, in the same transaction. For source-tied side effects (stop a timer, release a slot). Drag-moves only (not destroy/programmatic/quick-add); skipped on same-column reorders |
243
+ | `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column — opens a modal to collect input, then commits atomically. See [Interaction on drop](#interaction-on-drop) |
244
+ | `role:` | `:backlog`, `:done`, `:lost` | `nil` | Preset shorthand (see below) |
245
+ | `collapsed:` | Boolean | `false` | Start collapsed |
246
+ | `add:` | Boolean | `false` | Show `+ Add` quick-add button |
247
+ | `accepts:` | `true`, `false`, or Array of keys | `true` | Which source columns may drop here (structural, client-hintable). `true` = all, `false` = none, `[:doing]` = only from `:doing`. Record/user conditions go in `kanban_move?` instead (it sees the record and `from`/`to`); a `Proc` here raises |
248
+ | `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
249
+ | `wip:` | Integer | `nil` | Work-in-progress limit. Cross-column drops that would exceed this count are rejected |
250
+
251
+ ### Role presets
252
+
253
+ | Role | Equivalent to |
254
+ |------|---------------|
255
+ | `:backlog` | `add: true` |
256
+ | `:done` | `color: :green, collapsed: true` |
257
+ | `:lost` | `color: :red, collapsed: true` |
258
+
259
+ `:done` and `:lost` are the two terminal roles (both collapsed by default) — the
260
+ won/lost pair for pipelines like leads, deals, or tickets; the colour signals the
261
+ outcome.
262
+
263
+ Explicitly provided options override the preset.
264
+
265
+ **Collapse toggle:** Click the arrow button in any column header to collapse or expand it. Collapsed columns render as a thin vertical strip with the label rotated. The Stimulus controller persists each column's collapsed/expanded state to `localStorage` (key: `pu-kanban:<collection-path>:<column-key>:collapsed`) so the preference survives page reloads. The `collapsed:` DSL option sets the server-rendered initial state; `localStorage` takes precedence on subsequent loads.
266
+
267
+ ---
268
+
269
+ ## Column actions
270
+
271
+ Declare actions inside a column block to run an interaction against that column's cards:
272
+
273
+ ```ruby
274
+ column :done,
275
+ scope: -> { where(status: "done") },
276
+ on_enter: :mark_done! do
277
+
278
+ action :archive_all,
279
+ interaction: ArchiveTasksInteraction, # must be a bulk interaction (has `attribute :resources`)
280
+ on: :all, # :all (default) or :visible
281
+ label: "Archive all",
282
+ icon: Phlex::TablerIcons::Archive,
283
+ confirmation: "Archive all done tasks?"
284
+ end
285
+ ```
286
+
287
+ - `on: :all` — passes IDs of **all** cards in the column (ignoring `per_column`).
288
+ - `on: :visible` — passes IDs of only the rendered, `per_column`-capped cards.
289
+
290
+ Column actions are rendered as buttons in the column header. They open the normal interactive-action modal (with form, authorization, success/failure handling) pre-loaded with the column's card IDs.
291
+
292
+ ---
293
+
294
+ ## Interaction on drop
295
+
296
+ A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip — a reason, a notification email, an audit entry.
297
+
298
+ ```ruby
299
+ column :lost,
300
+ scope: -> { where(status: "lost") },
301
+ enter_interaction: MarkLostInteraction
302
+ ```
303
+
304
+ `enter_interaction:` takes an **Interaction class**. It must be **record-scoped** — it declares `attribute :resource` and acts on the single dropped card. A bulk (`attribute :resources`) interaction is not valid here; that shape is for [column actions](#column-actions).
305
+
306
+ The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards — it is reachable only by dropping a card into the column.
307
+
308
+ ### The interaction
309
+
310
+ A drop interaction is an ordinary record-scoped interaction — nothing kanban-specific in the class:
311
+
312
+ ```ruby
313
+ class MarkLostInteraction < ResourceInteraction
314
+ presents label: "Mark Lost",
315
+ icon: Phlex::TablerIcons::X
316
+
317
+ attribute :resource
318
+ attribute :reason, :string
319
+
320
+ input :reason
321
+
322
+ validates :reason, presence: true
323
+
324
+ def execute
325
+ resource.update!(status: "lost", lost_reason: reason)
326
+ succeed(resource).with_message("Marked as lost")
327
+ end
328
+ end
329
+ ```
330
+
331
+ ### Authorization
332
+
333
+ The drop is authorized by the single **`kanban_move?`** predicate — the interaction has **no policy method of its own**. To gate this specific transition, branch on the destination column, which `kanban_move?` reads from its authorization context (`kanban_to`):
334
+
335
+ ```ruby
336
+ class TaskPolicy < ResourcePolicy
337
+ def kanban_move?
338
+ return update? if kanban_to&.key == :lost # who may mark a task lost
339
+ super
340
+ end
341
+ end
342
+ ```
343
+
344
+ This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition — no per-interaction predicate, no `condition:` proc. If the check fails the drop is refused and the card stays put. See [Authorization](../reference/kanban/authorization) for the full `from`/`to` context.
345
+
346
+ ### Two flows, split by intent
347
+
348
+ - **Move flow (drag a card cross-column).** Dropping into the column opens the interaction's form as a **modal** to collect input (the `reason`). On submit, the membership write (`on_enter`, if any), the interaction, and the repositioning are committed in **one atomic transaction**.
349
+ - **Quick-add (`+ Add`).** The `+ Add` button creates the record, then applies `on_enter` + positioning **post-create** (see [Quick-add](#quick-add)). The `enter_interaction` is **not** involved in quick-add.
350
+
351
+ ### Author contract: on_enter owns membership, the interaction owns extras
352
+
353
+ A column can declare `on_enter:` and `enter_interaction:` together. When it does:
354
+
355
+ - `on_enter` owns the **membership attribute** (the column's grouping value, e.g. `status`).
356
+ - `enter_interaction` owns the **extras** — the reason, the mail, the audit trail.
357
+
358
+ If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that — `on_enter` sets `status = "blocked"` and the interaction's `execute` re-asserts `status: "blocked"` while adding the reason:
359
+
360
+ ```ruby
361
+ column :blocked,
362
+ scope: -> { where(status: "blocked") },
363
+ on_enter: ->(r) { r.status = "blocked" },
364
+ enter_interaction: BlockTaskInteraction
365
+ ```
366
+
367
+ When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything — including the membership write — because there is no `on_enter` to do it.
368
+
369
+ ### Same-column drops run positioning only
370
+
371
+ Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires — both represent *entering* a column, and a same-column reorder is not an entry. Only cross-column drops trigger them.
372
+
373
+ ### Atomicity and failure
374
+
375
+ Interaction validation failure rolls the **whole transaction back** — the membership write included — and re-renders the modal with errors. The move context is preserved, so the user can fix the input and resubmit. Nothing is persisted on failure. Keep side-effects on `deliver_later` (mailers, jobs): a rolled-back failure then sends no stray mail, because the enqueue never commits.
376
+
377
+ ### Success feedback and the response limitation
378
+
379
+ On success the board's column frames re-render and the modal closes. The interaction's success **message** (`succeed(resource).with_message("Marked as lost")`) is surfaced as a toast.
380
+
381
+ ::: warning Custom success responses are not honored on the drop path
382
+ A drop interaction's custom success *response* — `with_redirect_response`, `with_file_response`, etc. — is **not** honored when it runs from a drop: the board simply re-renders and closes the modal. Keep drop interactions to simple state + extras mutations, and use `.with_message` for feedback.
383
+ :::
384
+
385
+ There is no card "snap-back" to worry about on cancel — native drag never moves the card's DOM node, so canceling the modal just closes it and the card stays where it was.
386
+
387
+ ---
388
+
389
+ ## Positioning
390
+
391
+ By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
392
+
393
+ 1. A `decimal` database column (precision ≥ 10, scale ≥ 6 recommended).
394
+ 2. `include Plutonium::Positioning` in the model.
395
+ 3. `positioned_on :position, scope: :status` — the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
396
+
397
+ ### Position modes
398
+
399
+ ```ruby
400
+ kanban do
401
+ # Mode A (default) — delegate to Plutonium::Positioning.
402
+ # Uses :position attribute, requires the model concern.
403
+ position_on :position
404
+
405
+ # Mode A with a custom attribute name:
406
+ position_on :sort_order
407
+
408
+ # Mode B — BYO positioning. The block receives a Move struct.
409
+ # Use when you want to call a custom service or use a different ordering scheme.
410
+ position_on :sort_order do |move|
411
+ # move.record — the dropped record
412
+ # move.column — the destination column key (Symbol)
413
+ # move.prev — the record immediately before the drop slot (or nil)
414
+ # move.next — the record immediately after the drop slot (or nil)
415
+ # move.index — 0-based insertion index within the destination column
416
+ MyPositioningService.call(move.record, prev: move.prev, next: move.next)
417
+ end
418
+
419
+ # Mode C — no ordering. Cards render in the relation's default order.
420
+ # On-drop still fires; position is just never updated.
421
+ position_on false
422
+ end
423
+ ```
424
+
425
+ See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted.
426
+
427
+ ---
428
+
429
+ ## Per-column card limit
430
+
431
+ ```ruby
432
+ kanban do
433
+ per_column 25
434
+ # ...
435
+ end
436
+ ```
437
+
438
+ Each column loads at most 25 cards. When the total exceeds the limit, a `+N more` footer appears. Column actions with `on: :visible` respect the cap; `on: :all` ignores it.
439
+
440
+ ---
441
+
442
+ ## Quick-add
443
+
444
+ When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal.
445
+
446
+ The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record — so the new card lands in the clicked column, appended to the bottom. `on_enter` runs against a real, persisted record (exactly as it does for a drag), so `update!`-style callbacks and any side effects behave identically and fire once, on the actual create.
447
+
448
+ ::: warning Give your grouping column a default
449
+ Because `on_enter` runs **after** the record is saved, the record must be creatable **without** a grouping value. Give your grouping column (e.g. `status`) a database or model default. If it is `NOT NULL` with no default, quick-add create fails validation before `on_enter` can set it.
450
+ :::
451
+
452
+ If `on_enter` (or positioning) raises after the record was created, the create is **not** rolled back: the record is kept in its default column (validly positioned there) and the failure is surfaced as a toast.
453
+
454
+ Authorization: the button is only rendered when `create?` returns `true` in the current policy.
455
+
456
+ ---
457
+
458
+ ## Authorization
459
+
460
+ Every drag-and-drop move is authorized by the `kanban_move?` policy predicate. By default it delegates to `update?`. Override it in your policy to decouple board move rights from full edit-form access:
461
+
462
+ ```ruby
463
+ class TaskPolicy < ResourcePolicy
464
+ # Board drags require only :member role; full edit requires :admin.
465
+ def kanban_move?
466
+ user.member?
467
+ end
468
+
469
+ def update?
470
+ user.admin?
471
+ end
472
+ end
473
+ ```
474
+
475
+ When `kanban_move?` returns `false`, the board is rendered read-only (dragging is disabled). See [Authorization reference](/reference/kanban/authorization) for details.
476
+
477
+ ---
478
+
479
+ ## Realtime updates
480
+
481
+ Enable opt-in realtime broadcasting so every viewer of the same board sees moves immediately:
482
+
483
+ ```ruby
484
+ kanban do
485
+ realtime true
486
+ # ...
487
+ end
488
+ ```
489
+
490
+ After a successful move, Plutonium broadcasts the updated column frames to all connected viewers on the same stream. Stream names are tenant-scoped: viewers of different tenant entities can never cross-contaminate each other's streams. See [Reference › Kanban › DSL](/reference/kanban/dsl#realtime) for the stream name format.
491
+
492
+ ### Setup (required for realtime to actually update other viewers)
493
+
494
+ Plutonium emits the `<turbo-cable-stream-source>` subscription element and broadcasts on the server, but the **client must have an ActionCable consumer** to receive it. Plutonium's bundled JavaScript ships `@hotwired/turbo` only (no cable client), so you must wire the rest up yourself:
495
+
496
+ 1. **Gems** — `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
497
+ 2. **Cable adapter** (`config/cable.yml`) — `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
498
+ 3. **Mount ActionCable** — `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
499
+ 4. **Load the cable client in your app's JavaScript** — this is the step most people miss. Add **one** of:
500
+ ```js
501
+ // app pack, alongside your other imports
502
+ import "@hotwired/turbo-rails" // registers <turbo-cable-stream-source> + a consumer
503
+ ```
504
+ …or, if you only want ActionCable:
505
+ ```js
506
+ import * as ActionCable from "@rails/actioncable"
507
+ window.ActionCable ||= ActionCable
508
+ ```
509
+ Without this, the server broadcasts but no browser is subscribed, so other viewers won't update until they reload.
510
+
511
+ ::: tip Verify it
512
+ With two browser tabs on the same board, move a card in one — the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
513
+ :::
514
+
515
+ ---
516
+
517
+ ## Lazy loading
518
+
519
+ By default (`lazy true`), each column is a Turbo Frame that loads its card list on demand when it enters the viewport. Set `lazy false` to load all columns eagerly on the initial page request:
520
+
521
+ ```ruby
522
+ kanban do
523
+ lazy false
524
+ # ...
525
+ end
526
+ ```
527
+
528
+ ---
529
+
530
+ ## Switching views
531
+
532
+ The index page renders a view-switcher toggle when more than one index view is available (`:table`, `:grid`, `:kanban`). Declare the default:
533
+
534
+ ```ruby
535
+ class TaskDefinition < ResourceDefinition
536
+ kanban do
537
+ # ...
538
+ end
539
+
540
+ # Call AFTER the kanban block — :kanban isn't a valid default until
541
+ # `kanban` has enabled the view. Reversing the order raises ArgumentError
542
+ # at class load.
543
+ default_index_view :kanban
544
+ end
545
+ ```
546
+
547
+ To make the kanban board the **only** view (hide the switcher), call `index_views :kanban` after the block:
548
+
549
+ ```ruby
550
+ class TaskDefinition < ResourceDefinition
551
+ kanban do
552
+ # ...
553
+ end
554
+
555
+ index_views :kanban # drop :table/:grid; kanban is the sole view
556
+ end
557
+ ```