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,293 @@
1
+ # Kanban DSL Reference
2
+
3
+ ::: warning Experimental
4
+ Kanban boards are experimental — the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ Complete reference for the `kanban do…end` block declared inside a resource Definition.
8
+
9
+ ## Entry point
10
+
11
+ ```ruby
12
+ class PostDefinition < ResourceDefinition
13
+ kanban do
14
+ # board-level options + column declarations
15
+ end
16
+ end
17
+ ```
18
+
19
+ Calling `kanban` automatically adds `:kanban` to `defined_index_views`. To set it as the default view:
20
+
21
+ ```ruby
22
+ default_index_view :kanban
23
+ ```
24
+
25
+ To make it the only view:
26
+
27
+ ```ruby
28
+ index_views :kanban
29
+ kanban do ... end
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Board-level options
35
+
36
+ ### `per_column(n)`
37
+
38
+ ```ruby
39
+ per_column 25
40
+ ```
41
+
42
+ Maximum cards rendered per column. When the column total exceeds `n`, a `+N more` footer appears. Column actions with `on: :all` still operate against the full column set; `on: :visible` is capped to the rendered subset.
43
+
44
+ Default: `nil` (unlimited).
45
+
46
+ ---
47
+
48
+ ### `position_on`
49
+
50
+ Controls how card positions are persisted after a drag-and-drop. Three modes:
51
+
52
+ #### Mode A — delegate to `Plutonium::Positioning` (default)
53
+
54
+ ```ruby
55
+ # Default: uses :position attribute
56
+ # (no explicit call needed if the model includes Plutonium::Positioning)
57
+
58
+ # Custom attribute name:
59
+ position_on :sort_order
60
+ ```
61
+
62
+ Requires the model to:
63
+ 1. `include Plutonium::Positioning`
64
+ 2. Call `positioned_on :position, scope: :grouping_attribute`
65
+ 3. Have a `decimal` column for the position attribute — add it with the `t.position` migration helper (a tuned `decimal(16,8)`) — see [Positioning › Migration](/reference/kanban/positioning#migration)
66
+
67
+ On drop, calls `record.reposition!(prev_record:, next_record:)` which computes the decimal midpoint and updates the record.
68
+
69
+ #### Mode B — BYO block
70
+
71
+ ```ruby
72
+ position_on :sort_order do |move|
73
+ # move is a Plutonium::Kanban::Positioning::Move value object:
74
+ # move.record — the dropped ActiveRecord record
75
+ # move.column — destination column key (Symbol)
76
+ # move.prev — record immediately before the insertion slot, or nil
77
+ # move.next — record immediately after the insertion slot, or nil
78
+ # move.index — 0-based insertion index within the destination column
79
+ move.record.update!(sort_order: compute_position(move.prev, move.next))
80
+ end
81
+ ```
82
+
83
+ The block is evaluated via `call` (not `instance_exec`) — it is a plain Ruby proc/lambda.
84
+
85
+ Plutonium still orders column cards by `sort_order` (the first argument); your block is responsible only for persisting the new value.
86
+
87
+ #### Mode C — disabled
88
+
89
+ ```ruby
90
+ position_on false
91
+ ```
92
+
93
+ No ordering is applied. Cards render in the relation's natural order. On drop, only `on_drop` fires (if set); the position attribute is never touched.
94
+
95
+ ---
96
+
97
+ ### `realtime(v = true)` {#realtime}
98
+
99
+ ```ruby
100
+ realtime true
101
+ ```
102
+
103
+ Enables ActionCable broadcasting after every successful move. After a drop, Plutonium pushes the refreshed column frames to all viewers subscribed to this board's stream.
104
+
105
+ **Stream name format:**
106
+
107
+ ```
108
+ ["kanban", "<tenant_gid_or_global>", "<ResourceClass.name>"]
109
+ ```
110
+
111
+ - Tenant-scoped portals use the entity's Global ID parameter as the second segment.
112
+ - Portals without entity scoping use the literal string `"global"`.
113
+
114
+ Two viewers share a stream only if they have the same resource class **and** the same scoped entity — cross-tenant leakage is impossible by construction.
115
+
116
+ Requires `turbo-rails` + ActionCable (gems), a cable adapter in `config/cable.yml` (Redis/Solid Cable in multi-process production), ActionCable mounted at `/cable`, **and** an ActionCable client loaded in your app's JavaScript. Plutonium's bundle ships `@hotwired/turbo` only — without `@hotwired/turbo-rails` (or `@rails/actioncable`) in your pack, the `<turbo-cable-stream-source>` never connects and other viewers won't update. Server-side broadcasting works regardless; this is purely the client subscription. See the [guide's Realtime setup](/guides/kanban#setup-required-for-realtime-to-actually-update-other-viewers).
117
+
118
+ Default: `false`.
119
+
120
+ ---
121
+
122
+ ### `lazy(v = true)`
123
+
124
+ ```ruby
125
+ lazy false # eager-load all columns on initial page request
126
+ ```
127
+
128
+ When `true` (the default), each column is a Turbo Frame that loads its card list on demand (lazy loading). The frame loads when it enters the viewport.
129
+
130
+ When `false`, all column frames are loaded in the initial page request (one HTTP request per column).
131
+
132
+ ---
133
+
134
+ ### `show_in(mode)` {#show_in}
135
+
136
+ ```ruby
137
+ show_in :modal # open a card's show page in a centered modal dialog
138
+ show_in :page # navigate the whole page to the show route
139
+ ```
140
+
141
+ Overrides — **for this board** — where clicking a card opens the record's show page:
142
+
143
+ - `:modal` — the card's show link targets the layout's `remote_modal` frame, so the show page renders in a **centered** dialog. (Show is always centered — deliberately not the definition's `modal_mode`, which styles `new`/`edit`.)
144
+ - `:page` — the card's show link targets `_top`, navigating the whole page to the show route.
145
+
146
+ When `show_in` is **not** set on the board, the board inherits the definition's [`show_in`](/reference/resource/definition#show_in) (which itself defaults to `:page`). So to open cards in a modal you can set it on the board, or once on the definition (which also covers the table and grid views).
147
+
148
+ Either mode escapes the column's lazy turbo-frame — `:page` replaces the whole page, and the `remote_modal` frame lives in the layout (resolved document-wide), so it opens outside the column. No per-card configuration is needed; the show page detects the modal frame (`in_modal?`) and wraps its details in the centered modal chrome. From inside the modal, an expand icon (or ⌘/Ctrl-click on the card) opens the full page in a new tab.
149
+
150
+ An unknown mode raises `ArgumentError`.
151
+
152
+ ---
153
+
154
+ ### `card_fields(**slots)`
155
+
156
+ ```ruby
157
+ card_fields(
158
+ header: :title,
159
+ subheader: :assignee,
160
+ meta: [:due_date, :priority]
161
+ )
162
+ ```
163
+
164
+ Overrides the slot layout for every kanban card on this board, using the same slot names as `grid_fields` (`:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`). The kanban card renderer resolves its slots as `card_fields || definition.grid_fields`, so a board-level `card_fields` takes precedence over the resource's `grid_fields`.
165
+
166
+ When `card_fields` is not set, cards fall back to the resource definition's `grid_fields`. If neither is declared, the card renders the default header-only layout.
167
+
168
+ ---
169
+
170
+ ## Static columns
171
+
172
+ Declare columns at class-load time:
173
+
174
+ ```ruby
175
+ kanban do
176
+ column :todo,
177
+ label: "To Do",
178
+ color: :blue,
179
+ scope: -> { where(status: "todo") },
180
+ on_drop: ->(r) { r.update!(status: "todo") },
181
+ role: :backlog
182
+
183
+ column :done,
184
+ scope: -> { where(status: "done") },
185
+ on_drop: :mark_done!,
186
+ accepts: [:doing],
187
+ role: :done do
188
+ action :archive_all, interaction: ArchiveTasksInteraction, on: :all
189
+ end
190
+ end
191
+ ```
192
+
193
+ ### Column options
194
+
195
+ | Option | Type | Default | Description |
196
+ |--------|------|---------|-------------|
197
+ | `label:` | String | `key.to_s.titleize` | Column header label |
198
+ | `color:` | Symbol or String | `nil` | Header color dot. Named colors: `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`. Raw CSS string also accepted |
199
+ | `scope:` | Symbol or Proc | `nil` | Relation filter for this column. **Symbol** → `relation.public_send(sym)` (named AR scope). **Proc** → 0-arg lambda called via `instance_exec` on the relation, e.g. `-> { where(status: "todo") }` |
200
+ | `on_drop:` | Symbol or Proc | `nil` | Fired when a card is dropped into this column. **Symbol** → `record.public_send(sym)`. **Proc** → 1-arg lambda `->(record) { … }` where `self` inside the block is the view context (giving access to `current_user`, helpers, etc.). The callback may assign attributes in memory (`r.status = :done`) or call `update!` directly; if the record has unsaved changes after `on_drop` returns the controller saves it automatically. |
201
+ | `role:` | `:backlog`, `:done` | `nil` | Applies a preset (see below) |
202
+ | `collapsed:` | Boolean | `false` | Column starts collapsed (a thin strip with the label rotated). The Stimulus controller persists the toggled state to `localStorage` (key: `pu-kanban:<path>:<column-key>:collapsed`) so the user preference survives page reloads; this DSL value sets the server-rendered initial state only. |
203
+ | `add:` | Boolean | `false` | Show a `+ Add` quick-add button |
204
+ | `accepts:` | `true`, `false`, Array, or Proc | `true` | Drop policy. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. A 1-arg Proc `->(record) { … }` is evaluated **per-card on the server** at drop time (via `accepts_record?`) and returns a boolean — e.g. `->(task) { task.status == "doing" }`. The client-side drag hint treats a Proc column as permissive (`data-kanban-accepts="all"`) since the browser can't run the Proc; the server enforces it precisely on every move |
205
+ | `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
206
+ | `wip:` | Integer | `nil` | WIP limit. Reject cross-column drops when `dest_count + 1 > wip`. Has no effect on same-column reordering |
207
+
208
+ ### Role presets
209
+
210
+ | Role | Equivalent options |
211
+ |------|--------------------|
212
+ | `:backlog` | `add: true` |
213
+ | `:done` | `color: :green, collapsed: true` |
214
+
215
+ Explicitly passed options override the preset. Unknown role values raise `ArgumentError`.
216
+
217
+ ---
218
+
219
+ ## Dynamic columns
220
+
221
+ Use `columns do…end` when the column list depends on the current request:
222
+
223
+ ```ruby
224
+ kanban do
225
+ columns do
226
+ # `self` is the view context — current_user, params, and helpers all work.
227
+ current_user.visible_statuses.map do |status|
228
+ Plutonium::Kanban::Column.new(
229
+ :"status_#{status.id}",
230
+ label: status.name,
231
+ color: status.color_symbol,
232
+ scope: -> { where(status_id: status.id) },
233
+ on_drop: ->(r) { r.update!(status_id: status.id) }
234
+ )
235
+ end
236
+ end
237
+ end
238
+ ```
239
+
240
+ The block is evaluated at request time. You can mix static pre-declared columns with a dynamic block: if both are present, the `columns` block takes precedence (the board is considered dynamic).
241
+
242
+ ::: warning Column action registration for dynamic boards
243
+ Column actions declared inside a `columns do…end` block **cannot be auto-registered** at class-load time. Register the interaction as a top-level definition `action` as well:
244
+
245
+ ```ruby
246
+ class TaskDefinition < ResourceDefinition
247
+ action :archive_column_tasks, interaction: ArchiveTasksInteraction
248
+
249
+ kanban do
250
+ columns do
251
+ build_status_columns.each do |col|
252
+ col.action :archive_column_tasks, interaction: ArchiveTasksInteraction, on: :all
253
+ col
254
+ end
255
+ end
256
+ end
257
+ end
258
+ ```
259
+ :::
260
+
261
+ ---
262
+
263
+ ## Column actions
264
+
265
+ Declare actions inside a column `do…end` block:
266
+
267
+ ```ruby
268
+ column :done,
269
+ scope: -> { where(status: "done") },
270
+ on_drop: :mark_done! do
271
+
272
+ action :archive_all,
273
+ interaction: ArchiveTasksInteraction,
274
+ on: :all,
275
+ label: "Archive all",
276
+ icon: Phlex::TablerIcons::Archive,
277
+ confirmation: "Archive all done tasks?"
278
+ end
279
+ ```
280
+
281
+ ### Action options
282
+
283
+ | Option | Type | Required | Description |
284
+ |--------|------|----------|-------------|
285
+ | `interaction:` | Class | Yes | An interaction class. Must have `attribute :resources` (plural) — it runs as a bulk action |
286
+ | `on:` | `:all` or `:visible` | No (default `:all`) | `:all` passes IDs of all column cards (ignoring `per_column`). `:visible` passes only the rendered, capped subset |
287
+ | `label:` | String | No | Button text. Defaults to `key.to_s.humanize` |
288
+ | `icon:` | Phlex icon class | No | Icon rendered before the label |
289
+ | `confirmation:` | String | No | Browser `confirm()` message shown before the action fires |
290
+
291
+ Column actions are rendered as small buttons in the column header. They open the standard interactive-action modal with full authorization, form rendering, and success/failure handling.
292
+
293
+ **Auto-registration:** For static columns, the `kanban` DSL automatically calls `action(key, interaction:, …)` at definition class-load time so the bulk route resolves. For dynamic `columns do…end` boards you must register the interaction manually (see warning above).
@@ -0,0 +1,40 @@
1
+ # Kanban Reference
2
+
3
+ Reference documentation for the Plutonium kanban board feature.
4
+
5
+ ## In this section
6
+
7
+ | Page | What it covers |
8
+ |------|---------------|
9
+ | [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL — board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
10
+ | [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
11
+ | [Authorization](/reference/kanban/authorization) | `kanban_move?` policy predicate, read-only fallback, separating move rights from edit rights |
12
+
13
+ ## Quick start
14
+
15
+ ```ruby
16
+ # app/definitions/task_definition.rb
17
+ class TaskDefinition < ResourceDefinition
18
+ kanban do
19
+ per_column 25
20
+
21
+ column :todo,
22
+ scope: -> { where(status: "todo") },
23
+ on_drop: ->(r) { r.update!(status: "todo") },
24
+ role: :backlog
25
+
26
+ column :doing,
27
+ scope: -> { where(status: "doing") },
28
+ on_drop: ->(r) { r.update!(status: "doing") },
29
+ wip: 3
30
+
31
+ column :done,
32
+ scope: -> { where(status: "done") },
33
+ on_drop: :mark_done!,
34
+ accepts: [:doing],
35
+ role: :done
36
+ end
37
+ end
38
+ ```
39
+
40
+ See the [Kanban guide](/guides/kanban) for a full walkthrough including model setup and migrations.
@@ -0,0 +1,162 @@
1
+ # Kanban Positioning
2
+
3
+ Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row — no bulk renumbering. The one exception is rare **rebalancing**: when the same slot has been subdivided ~20 times and the gap between two neighbors shrinks below `1e-6`, Plutonium renumbers that one scope group back to clean integers before inserting (see [Gap exhaustion](#rebalancing)).
4
+
5
+ ## `Plutonium::Positioning` concern
6
+
7
+ Include this concern in any model you want to position:
8
+
9
+ ```ruby
10
+ class Task < ApplicationRecord
11
+ include Plutonium::Positioning
12
+
13
+ positioned_on :position, scope: :status
14
+ end
15
+ ```
16
+
17
+ ### `positioned_on(column = :position, scope: nil)`
18
+
19
+ Configures positional ordering for the model.
20
+
21
+ | Argument | Description |
22
+ |----------|-------------|
23
+ | `column` | The `decimal` database column that stores positions. Default: `:position` |
24
+ | `scope:` | Group positions by this attribute. Records with different scope values are ordered independently. `nil` = single global ordering across all rows |
25
+
26
+ After calling `positioned_on`, the model gets:
27
+ - A `before_create` callback that assigns the next position in the scope group (appends to end).
28
+ - A `reposition!(prev_record:, next_record:)` instance method.
29
+ - A `backfill_positions!(order: :created_at)` class method.
30
+
31
+ ### Migration
32
+
33
+ Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't get the scale wrong:
34
+
35
+ ```ruby
36
+ create_table :tasks do |t|
37
+ t.string :status, null: false, default: "todo"
38
+ t.position # decimal :position, precision: 16, scale: 8
39
+ t.timestamps
40
+ end
41
+ add_index :tasks, [:status, :position] # match your scope attribute
42
+ ```
43
+
44
+ Adding the column to an existing table works the same way in a `change_table` block:
45
+
46
+ ```ruby
47
+ class AddPositionToTasks < ActiveRecord::Migration[8.1]
48
+ def change
49
+ change_table(:tasks) { |t| t.position }
50
+ add_index :tasks, [:status, :position]
51
+ end
52
+ end
53
+ ```
54
+
55
+ `t.position` accepts a custom column name and any `column` options:
56
+
57
+ ```ruby
58
+ t.position :sort_order # custom name
59
+ t.position :position, index: true # also add a single-column index
60
+ t.position :position, scale: 10 # override precision/scale
61
+ ```
62
+
63
+ ::: tip Why the helper picks `scale: 8`
64
+ If you write the column by hand, give it at least **two more decimal places than `EPSILON` (`1e-6`)** — i.e. `scale: 8` or higher. Rebalancing triggers when a gap drops below `1e-6`, so a column that can store smaller values still has room to write the final midpoint cleanly. A `scale: 6` column has no headroom: the last subdivision before a rebalance can round to a neighbor and momentarily collide. `t.position` defaults to `scale: 8`, which is safe.
65
+ :::
66
+
67
+ ---
68
+
69
+ ## `reposition!(prev_record:, next_record:)`
70
+
71
+ Moves a record so it sits between `prev_record` and `next_record` in its scope group. Pass `nil` for an end to prepend or append.
72
+
73
+ ```ruby
74
+ task.reposition!(prev_record: card_a, next_record: card_b)
75
+ task.reposition!(prev_record: nil, next_record: first_card) # prepend
76
+ task.reposition!(prev_record: last_card, next_record: nil) # append
77
+ ```
78
+
79
+ **Arithmetic:**
80
+ - Both nil → `0.0` (first item in empty group)
81
+ - Only `prev_record` → `prev.position + 1` (append)
82
+ - Only `next_record` → `next.position - 1` (prepend)
83
+ - Both present → `(prev.position + next.position) / 2.0` (midpoint)
84
+
85
+ ### Gap exhaustion (rebalancing) {#rebalancing}
86
+
87
+ Each midpoint insert into the *same* slot halves the gap (`1.0 → 0.5 → 0.25 → …`), so after roughly 20 consecutive insertions the gap drops below `EPSILON` (`1e-6`). At that point `reposition!` rebalances **only that scope group** — renumbering every row in the group to fresh integers (`1.0, 2.0, 3.0, …`) in current-position order, inside a transaction — then reloads the two neighbors and writes the new midpoint. Other scope groups are untouched. End moves (a `nil` neighbor) never rebalance: they always have integer room via `prev ± 1`.
88
+
89
+ ---
90
+
91
+ ## `backfill_positions!(order: :created_at)`
92
+
93
+ Numbers all existing rows per scope group as `1.0, 2.0, 3.0, …` sorted by `order`. Safe to run on an empty table. Use this in a migration or seed task to initialize positions on existing data:
94
+
95
+ ```ruby
96
+ # In a migration after adding the column:
97
+ Task.backfill_positions!(order: :created_at)
98
+ ```
99
+
100
+ ---
101
+
102
+ ## `position_on` DSL modes
103
+
104
+ The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:
105
+
106
+ ### Mode A — delegate (default)
107
+
108
+ ```ruby
109
+ kanban do
110
+ # Implicit: position_on :position
111
+ # Explicit with custom attribute:
112
+ position_on :sort_order
113
+ end
114
+ ```
115
+
116
+ On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning` and call `positioned_on`.
117
+
118
+ ### Mode B — BYO block
119
+
120
+ ```ruby
121
+ kanban do
122
+ position_on :sort_order do |move|
123
+ # move.record — the dropped record
124
+ # move.column — destination column key (Symbol)
125
+ # move.prev — record immediately before the slot (or nil)
126
+ # move.next — record immediately after the slot (or nil)
127
+ # move.index — 0-based insertion index within the destination column
128
+ move.record.update!(sort_order: my_position(move.prev, move.next))
129
+ end
130
+ end
131
+ ```
132
+
133
+ Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Kanban::Positioning::Move` argument — it is NOT `instance_exec`'d, so `self` is the proc's original binding.
134
+
135
+ ### Mode C — disabled
136
+
137
+ ```ruby
138
+ kanban do
139
+ position_on false
140
+ end
141
+ ```
142
+
143
+ No ordering is applied (relation is returned unchanged). On drop, `on_drop` still fires; the position attribute is never touched. Cards render in the relation's default order.
144
+
145
+ ---
146
+
147
+ ## Pure math helpers
148
+
149
+ Available as module-level methods without an AR instance:
150
+
151
+ ```ruby
152
+ Plutonium::Positioning.position_between(1.0, 3.0) # => 2.0
153
+ Plutonium::Positioning.position_between(nil, 5.0) # => 4.0 (prepend)
154
+ Plutonium::Positioning.position_between(5.0, nil) # => 6.0 (append)
155
+ Plutonium::Positioning.position_between(nil, nil) # => 0.0 (first item)
156
+
157
+ Plutonium::Positioning.gap_exhausted?(1.0, 1.0) # => true
158
+ Plutonium::Positioning.gap_exhausted?(1.0, 3.0) # => false
159
+ Plutonium::Positioning.gap_exhausted?(nil, 5.0) # => false
160
+ ```
161
+
162
+ `EPSILON = 1e-6` is the minimum gap before `gap_exhausted?` returns `true`.
@@ -681,6 +681,22 @@ end
681
681
 
682
682
  `modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win — see [Actions](./actions).
683
683
 
684
+ ### `show_in` {#show_in}
685
+
686
+ ```ruby
687
+ class PostDefinition < ResourceDefinition
688
+ show_in :modal # open the show page in a centered modal from table & grid links
689
+ # show_in :page # (default) full-page navigation to the show route
690
+ end
691
+ ```
692
+
693
+ Controls how the **show page** opens when a record is clicked in the table or grid (and serves as the default for a [kanban board](/reference/kanban/dsl#show_in), which can override it per-board):
694
+
695
+ - `:page` (default) — full-page navigation to the show route.
696
+ - `:modal` — the show page opens in a **centered** dialog. This is deliberately independent of `modal:`/`modal_mode` above (which styles `:new`/`:edit`) — show is always centered, never a slideover. From inside the modal an expand icon opens the full page in a new tab; ⌘/Ctrl-click (or middle-click) on the row/card does the same directly.
697
+
698
+ An unknown mode raises `ArgumentError`.
699
+
684
700
  ## Metadata panel (show page)
685
701
 
686
702
  A right-side aside on the show page rendering label/value rows. Keeps the main card focused on substance; chrome (timestamps, ownership, system flags) lives in the aside.
@@ -175,6 +175,42 @@ render field(:avatar).wrapped do |f|
175
175
  end
176
176
  ```
177
177
 
178
+ ### Password & secret fields {#password-fields}
179
+
180
+ `password_tag` renders a masking input that **never emits the stored value** into the DOM. A stored secret renders a fixed sentinel (masking both the value and its length); on submit:
181
+
182
+ | field state | result |
183
+ |---|---|
184
+ | untouched (sentinel) | kept — the stored secret is left unchanged |
185
+ | edited to a new value, then failed re-render | comes back **blank + `required`** so the user re-types it (a submitted secret is never echoed back) |
186
+ | cleared, then failed re-render | comes back blank, **not** `required` — the clear may be intentional, so it's allowed to stand |
187
+ | emptied | explicit clear (clear-by-blank) — the `required` guard only prevents an *accidental* blank submit |
188
+ | typed | set as the new value |
189
+
190
+ The sentinel is guarded client-side by the `password-sentinel` Stimulus controller: the first edit (a keystroke, paste, or **backspace**) wipes the whole field, so a partial edit can't corrupt the sentinel into a literal new password. New records and interaction forms (set-password, reset-password) render an honest empty field.
191
+
192
+ **Automatic detection.** A field is masked automatically when its name:
193
+
194
+ - equals `password`, `token`, or `salt`;
195
+ - starts with `encrypted_`;
196
+ - ends with `_password`, `_digest`, `_hash`, `_token`, `_key`, or `_salt`;
197
+ - contains `secret`.
198
+
199
+ This is a naming convenience, **not** a security guarantee — tune it per field:
200
+
201
+ ```ruby
202
+ # Opt OUT: render the value as a normal, readable text input
203
+ field :api_token, as: :string # a token the admin needs to copy
204
+ field :content_hash, as: :string # a checksum, not a secret
205
+ field :public_key, as: :string # *_key matches, but a public key is not secret
206
+
207
+ # Opt IN: mask a secret the heuristic still misses (e.g. no telltale name)
208
+ field :recovery_phrase, as: :password
209
+ ```
210
+
211
+ > [!WARNING]
212
+ > The heuristic is name-based and best-effort. A secret column with an unconventional name (e.g. `recovery_phrase`, `pin`) still renders its value into the page unless you set `as: :password`. Audit secret-bearing columns explicitly.
213
+
178
214
  ### Wrapped vs unwrapped
179
215
 
180
216
  - `wrapped` — includes label, hint, and error rendering. Use for normal form fields.
@@ -150,6 +150,8 @@ end
150
150
 
151
151
  Show pages with `permitted_associations` (see [Behavior › Policy](/reference/behavior/policies#association-permissions)) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports — they don't wrap.
152
152
 
153
+ If the policy permits **no fields**, the empty Details tab is dropped and the first association tab leads instead.
154
+
153
155
  ## Portal-specific overrides
154
156
 
155
157
  Each portal can override page classes independently. The portal definition inherits from the base definition, and its nested classes inherit from the base's nested classes: