plutonium 0.65.0 → 0.66.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plutonium-kanban
3
- description: Use BEFORE building or customizing a kanban board view for any Plutonium resource — the kanban do…end DSL, column declarations, card_fields, position_on modes, realtime, column actions, kanban_move? policy, and quick-add. The single source for "how do I add a kanban board to a resource".
3
+ description: 'Use BEFORE building or customizing a kanban board view for any Plutonium resource: the kanban do…end DSL, column declarations, card_fields, position_on modes, realtime, column actions, kanban_move? policy, and quick-add. The single source for "how do I add a kanban board to a resource".'
4
4
  ---
5
5
 
6
6
  # Plutonium Kanban
@@ -11,13 +11,14 @@ For field-level rendering on cards (card_fields slots), see [[plutonium-resource
11
11
 
12
12
  ## 🚨 Critical (read first)
13
13
 
14
- - **`kanban do…end` in the Definition auto-enables `:kanban`** in `defined_index_views` — exactly like `grid_fields` enables `:grid`. You do not need to call `index_views :kanban` separately unless you want to remove the table view.
14
+ - **`kanban do…end` in the Definition auto-enables `:kanban`** in `defined_index_views`, exactly like `grid_fields` enables `:grid`. You do not need to call `index_views :kanban` separately unless you want to remove the table view.
15
15
  - **The model needs `include Plutonium::Positioning::Model`** (and a decimal `position` column + `positioned_on` call) for drag ordering to work. Without it, cards render unordered and moves raise an error. Use `position_on false` to explicitly opt out.
16
- - **Static column actions are auto-registered** as interactive resource actions at class-load time. Dynamic boards (`columns do…end`) cannot introspect their columns at load time — declare any column-action interactions separately with top-level `action` calls.
17
- - **Moves bypass `permitted_attributes_for_update`** — the `on_enter` callback runs with full model access. Gate the move itself with `kanban_move?` in the policy.
16
+ - **Static column actions are auto-registered** as interactive resource actions at class-load time. Dynamic boards (`columns do…end`) cannot introspect their columns at load time, so declare any column-action interactions separately with top-level `action` calls.
17
+ - **Moves bypass `permitted_attributes_for_update`**: the `on_enter` callback runs with full model access. Gate the move itself with `kanban_move?` in the policy.
18
18
  - **Quick-add (`add: true`) only appears when `create?` is true** in the policy.
19
- - **Same-column drops = positioning only** — a reorder within a column fires neither `on_exit`, `on_enter`, nor an `enter_interaction`; they represent *leaving*/*entering* a column, so only cross-column drops trigger them.
20
- - **`on_exit:` is the source-side hook** — fired when a card LEAVES a column (before the destination's `on_enter`, in the same transaction). Use it for source-tied side effects (stop a timer, release a slot) the destination can't own. It fires only on drag-moves via `kanban_move`, NOT on destroy/programmatic changes/quick-add — for those, use an ActiveRecord callback.
19
+ - **Same-column drops = positioning only**: a reorder within a column fires neither `on_exit`, `on_enter`, nor an `enter_interaction`; they represent *leaving*/*entering* a column, so only cross-column drops trigger them.
20
+ - **`on_exit:` is the source-side hook**: fired when a card LEAVES a column (before the destination's `on_enter`, in the same transaction). "When a card leaves X, wherever it goes" is exactly `on_exit:` on X; don't rebuild it as a `before_save` status-diff callback. It fires only on drag-moves via `kanban_move`, NOT on destroy/programmatic changes/quick-add, so reach for a model callback only when the requirement explicitly covers those other paths (see `on_exit:` below).
21
+ - **`locked: true` stops cards LEAVING the column**, not entering it. To refuse incoming drops, use `accepts: false` (or an `accepts:` Array) on the destination.
21
22
  - **Use `on_enter:` / `enter_interaction:`, NOT `on_drop:` / `drop_interaction:`.** The old names were renamed. They still exist as deprecated aliases but **raise in development/test** (and only warn-and-map in production), so a definition using them fails your test suite. Always write the new names.
22
23
 
23
24
  ---
@@ -37,7 +38,7 @@ class Task < ApplicationRecord
37
38
  end
38
39
  ```
39
40
 
40
- Migration — add the position column with the `t.position` helper (a tuned `decimal(16,8)`; works in `create_table` and `change_table`). Don't hand-roll a small scale — `scale: 6` exactly matches the `1e-6` rebalance threshold and can round to a duplicate. Use `t.position` (scale 8) or ≥ 8 if hand-written:
41
+ Migration: add the position column with the `t.position` helper (a tuned `decimal(16,8)`; works in `create_table` and `change_table`). Don't hand-roll a small scale: `scale: 6` exactly matches the `1e-6` rebalance threshold and can round to a duplicate. Use `t.position` (scale 8) or ≥ 8 if hand-written:
41
42
 
42
43
  ```ruby
43
44
  create_table :tasks do |t|
@@ -50,6 +51,20 @@ create_table :tasks do |t|
50
51
  end
51
52
  ```
52
53
 
54
+ **Package models: the migration goes in the package.** For a model owned by a feature package (e.g. `Catalog::Product`), write the position migration under `packages/<package>/db/migrate/`, next to the table's create migration. `pu:res:scaffold --dest=<package>` puts migrations there (it roots generation at `packages/<package>`), and `Plutonium::Package::Engine` appends each package's `db/migrate` to the app's migration paths, so `rails db:migrate` picks it up. Plain `rails g migration` always writes to the main app's `db/migrate`; move the file into the package afterwards. Keeping a table's migrations together keeps the package self-contained. (A couple of older dummy-app migrations for catalog tables sit in `test/dummy/db/migrate`; don't copy that.)
55
+
56
+ ```ruby
57
+ # packages/catalog/db/migrate/20261002000000_add_position_to_catalog_products.rb
58
+ class AddPositionToCatalogProducts < ActiveRecord::Migration[8.0]
59
+ def change
60
+ change_table :catalog_products do |t|
61
+ t.position
62
+ t.index [:category_id, :position]
63
+ end
64
+ end
65
+ end
66
+ ```
67
+
53
68
  ---
54
69
 
55
70
  ## Minimal definition
@@ -87,13 +102,13 @@ index_views :kanban # remove table; kanban is the only view
87
102
  |---|---|---|
88
103
  | `per_column N` | Cap cards rendered per column; `+N more` footer when exceeded | unlimited |
89
104
  | `position_on :attr` | Custom attribute name for ordering (Mode A) | `:position` |
90
- | `position_on :attr do \|move\| … end` | BYO positioning block (Mode B) | — |
91
- | `position_on false` | No ordering or repositioning (Mode C) | — |
105
+ | `position_on :attr do \|move\| … end` | BYO positioning block (Mode B) | - |
106
+ | `position_on false` | No ordering or repositioning (Mode C) | - |
92
107
  | `card_fields(**slots)` | Override grid slot layout for cards; same slot keys as `grid_fields` | inherits `grid_fields` |
93
108
  | `realtime true` | ActionCable broadcast after every move | false |
94
109
  | `lazy false` | Eager-load all column frames on the initial request | `true` (lazy) |
95
110
  | `show_in :modal` / `:page` | Open a card's show page in a centered modal (`:modal`) or full-page (`:page`). Overrides the definition's `show_in` for this board | inherits definition (`:page`) |
96
- | `columns do … end` | Dynamic columns evaluated at request time with view context | — |
111
+ | `columns do … end` | Dynamic columns evaluated at request time with view context | - |
97
112
 
98
113
  ### `card_fields`
99
114
 
@@ -103,25 +118,25 @@ Overrides the grid card layout for kanban cards. Uses the same slot keys as `gri
103
118
  card_fields header: :title, meta: [:status, :priority], footer: :due_at
104
119
  ```
105
120
 
106
- Every slot is optional and omitting it drops that line — **except `footer`, which
121
+ Every slot is optional and omitting it drops that line, **except `footer`, which
107
122
  falls back to `:created_at`**. To render no footer at all, opt out explicitly:
108
123
 
109
124
  ```ruby
110
125
  card_fields header: :title, meta: [:status], footer: false
111
126
  ```
112
127
 
113
- Omitting `footer:` is the common cause of a card ending in a stray `—`: the
128
+ Omitting `footer:` is the common cause of a card ending in a stray dash placeholder: the
114
129
  fallback lands on `:created_at`, and if that isn't in the policy's
115
130
  `permitted_attributes_for_index` the value resolves to nil and renders as the
116
131
  blank placeholder. Either permit `created_at`, point `footer:` at a permitted
117
132
  field, or pass `footer: false`. (A *declared* slot that's merely blank still
118
- shows `—` by design, so cards keep an even height.)
133
+ shows the dash by design, so cards keep an even height.)
119
134
 
120
135
  ### `position_on` modes
121
136
 
122
- - **Mode A (default)** — delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning::Model`. Requires the model concern and a decimal column.
123
- - **Mode B (block)** — you write the persistence. Plutonium still orders by the attribute; the block only persists the new value. Block receives a `Plutonium::Kanban::Positioning::Move` (fields: `record`, `column`, `prev`, `next`, `index`).
124
- - **Mode C (`false`)** — no ordering, no repositioning. `on_enter` still fires.
137
+ - **Mode A (default)**: delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning::Model`. Requires the model concern and a decimal column.
138
+ - **Mode B (block)**: you write the persistence. Plutonium still orders by the attribute; the block only persists the new value. Block receives a `Plutonium::Kanban::Positioning::Move` (fields: `record`, `column`, `prev`, `next`, `index`).
139
+ - **Mode C (`false`)**: no ordering, no repositioning. `on_enter` still fires.
125
140
 
126
141
  ### `realtime`
127
142
 
@@ -133,9 +148,9 @@ realtime true
133
148
 
134
149
  ### `show_in`
135
150
 
136
- Where a card click opens the record's show page. `:modal` renders the show page in a **centered** dialog; `:page` is a full-page navigation. The show modal is always centered — deliberately NOT the definition's `modal_mode` (which styles `new`/`edit`). No per-card wiring: the `Show` page detects the modal frame (`in_modal?`) and wraps its details in the centered modal chrome.
151
+ Where a card click opens the record's show page. `:modal` renders the show page in a **centered** dialog; `:page` is a full-page navigation. The show modal is always centered, deliberately NOT the definition's `modal_mode` (which styles `new`/`edit`). No per-card wiring: the `Show` page detects the modal frame (`in_modal?`) and wraps its details in the centered modal chrome.
137
152
 
138
- `show_in` also exists **on the definition** (`show_in :modal` / `:page`, default `:page`), where it governs the table and grid show links too. The kanban board inherits the definition's value unless it sets its own — so set it once on the definition for everywhere, or on the board to override just the board.
153
+ `show_in` also exists **on the definition** (`show_in :modal` / `:page`, default `:page`), where it governs the table and grid show links too. The kanban board inherits the definition's value unless it sets its own, so set it once on the definition for everywhere, or on the board to override just the board.
139
154
 
140
155
  From inside the show modal, an expand icon (or ⌘/Ctrl/middle-click on the card) opens the full page in a new tab.
141
156
 
@@ -150,14 +165,14 @@ end
150
165
 
151
166
  ### Dynamic columns
152
167
 
153
- Evaluates the block at request time with the view context as `self` (`current_user`, `params`, `current_scoped_entity`, helpers all available). The block must return an Array of `Plutonium::Kanban::Column` objects — `column` is a DSL method only available outside the `columns` block. Declare any column-action interactions as top-level definition `action` calls — the block is not introspectable at class-load time.
168
+ Evaluates the block at request time with the view context as `self` (`current_user`, `params`, `current_scoped_entity`, helpers all available). The block must return an Array of `Plutonium::Kanban::Column` objects; `column` is a DSL method only available outside the `columns` block. Declare any column-action interactions as top-level definition `action` calls, since the block is not introspectable at class-load time.
154
169
 
155
- > **`enter_interaction:` is NOT supported on dynamic boards.** Its hidden action is registered from the static column list at class-load time, which a `columns do…end` board doesn't have, and the key is column-scoped/internal so there's no manual-registration escape hatch (unlike column actions). A drop into such a column is rejected with a snap-back — it does not crash. Use a static board if you need `enter_interaction:`.
170
+ > **`enter_interaction:` is NOT supported on dynamic boards.** Its hidden action is registered from the static column list at class-load time, which a `columns do…end` board doesn't have, and the key is column-scoped/internal so there's no manual-registration escape hatch (unlike column actions). A drop into such a column is rejected with a snap-back; it does not crash. Use a static board if you need `enter_interaction:`.
156
171
 
157
172
  ```ruby
158
173
  kanban do
159
174
  columns do
160
- # `self` is the view context here — use Plutonium::Kanban::Column.new, NOT `column`.
175
+ # `self` is the view context here; use Plutonium::Kanban::Column.new, NOT `column`.
161
176
  current_user.teams.map do |team|
162
177
  Plutonium::Kanban::Column.new(
163
178
  :"team_#{team.id}",
@@ -186,7 +201,7 @@ column :key,
186
201
  collapsed: true, # starts collapsed (Stimulus persists toggle to localStorage)
187
202
  add: true, # show "+ Add" button (requires create?)
188
203
  accepts: true, # true (default), false, or Array of source keys (Proc raises)
189
- locked: false, # reject all incoming drops (server-enforced)
204
+ locked: false, # cards cannot be dragged OUT of this column (server-enforced)
190
205
  role: :backlog # :backlog, :done or :lost (see presets below)
191
206
  ```
192
207
 
@@ -198,7 +213,7 @@ column :key,
198
213
  | `:done` | `color: :green`, `collapsed: true` |
199
214
  | `:lost` | `color: :red`, `collapsed: true` |
200
215
 
201
- `:done` and `:lost` are the two terminal roles — collapsed by default, colour
216
+ `:done` and `:lost` are the two terminal roles, collapsed by default, colour
202
217
  signalling the outcome (`:done` = positive close, `:lost` = negative close). The
203
218
  natural pair for won/lost pipelines (leads, deals, tickets).
204
219
 
@@ -206,13 +221,15 @@ Explicit options override the preset (e.g. `role: :done, collapsed: false`).
206
221
 
207
222
  ### `accepts:`
208
223
 
209
- Structural drop topology — which **source columns** may drop cards here:
224
+ Structural drop topology: which **source columns** may drop cards here:
210
225
 
211
- - `true` (default) — any source allowed
212
- - `false` — column is a drop target but refuses everything (snap-back)
213
- - `Array` — list of source column keys allowed: `accepts: [:doing]`
226
+ - `true` (default): any source allowed
227
+ - `false`: column is a drop target but refuses everything (snap-back)
228
+ - `Array`: list of source column keys allowed: `accepts: [:doing]`
214
229
 
215
- Checked server-side; client-side visual hints read `data-kanban-accepts` (so the drag UI can grey out disallowed sources). **No Proc form** — a `Proc` raises `ArgumentError`. Record- or user-conditional rules belong in `kanban_move?`, which sees the record and the `from`/`to` columns (see Authorization below).
230
+ Checked server-side; client-side visual hints read `data-kanban-accepts` (so the drag UI can grey out disallowed sources). **No Proc form**: a `Proc` raises `ArgumentError`. Record- or user-conditional rules belong in `kanban_move?`, which sees the record and the `from`/`to` columns (see Authorization below).
231
+
232
+ `locked:` is the source-side counterpart: `locked: true` means no card can leave the column, whatever the destination. "Once verified it can never go back to Pending" can be written either way: `accepts: false` on Pending (nothing may enter it) or `locked: true` on Verified (nothing may leave it). Pick the one that matches the rule as stated, and remember `locked:` on Pending would not stop cards entering it.
216
233
 
217
234
  ### `on_enter:`
218
235
 
@@ -220,7 +237,7 @@ Runs inside a transaction after authorization and before repositioning. Receives
220
237
 
221
238
  ```ruby
222
239
  on_enter: ->(r) { r.update!(status: "done") } # update! directly
223
- on_enter: ->(r) { r.status = "done" } # attribute assignment — saved automatically
240
+ on_enter: ->(r) { r.status = "done" } # attribute assignment, saved automatically
224
241
  on_enter: :mark_done! # dispatched as record.mark_done!
225
242
  ```
226
243
 
@@ -237,13 +254,28 @@ column :doing,
237
254
  on_exit: ->(r) { r.stop_timer! } # leaving Doing (wherever it goes)
238
255
  ```
239
256
 
240
- Use it for side effects tied to the column being **left** — the destination's `on_enter` doesn't know where a card came from, so source concerns (stop a timer, release a WIP/lock, un-assign) belong here.
257
+ Use it for side effects tied to the column being **left**; the destination's `on_enter` doesn't know where a card came from, so source concerns (stop a timer, release a WIP/lock, un-assign) belong here.
258
+
259
+ ⚠️ It fires **only** on a drag-move through `kanban_move`, not on `destroy`, a programmatic `status` change elsewhere, or quick-add. Skipped on same-column reorders.
260
+
261
+ **`on_exit:` vs a model callback.** Default to `on_exit:` for anything phrased in board terms ("when a ticket leaves In progress, wherever it goes, add the elapsed time"). "Wherever it goes" means any destination column, which is what `on_exit:` covers. A `before_save` callback keyed on a status diff duplicates what the column already knows (which column the card left) and has to re-derive column membership from attributes, which breaks as soon as a column's `scope:` is more than one attribute value. It also fires on every save path, which is a different requirement. If the column owns its membership attribute (the column's `on_enter` writes `status`, so `status` stays out of `permitted_attributes_for_update`), the board is the only way a card leaves, and `on_exit:` is complete. Use a model callback only when the user explicitly wants other paths (edit form, API, import) covered too, and then drop the `on_exit:` rather than keeping both, or the time is counted twice.
262
+
263
+ ```ruby
264
+ column :in_progress,
265
+ label: "In progress",
266
+ scope: -> { where(status: "in_progress") },
267
+ on_enter: ->(t) { t.assign_attributes(status: "in_progress", started_at: Time.current) },
268
+ on_exit: ->(t) {
269
+ t.time_spent_seconds += (Time.current - t.started_at).to_i if t.started_at
270
+ t.started_at = nil
271
+ }
272
+ ```
241
273
 
242
- ⚠️ It fires **only** on a drag-move through `kanban_move` — not on `destroy`, a programmatic `status` change elsewhere, or quick-add. For "whenever this leaves, no matter how", use an ActiveRecord callback. Skipped on same-column reorders.
274
+ Assigned attributes are saved by the controller's `record.save!` after the hooks, along with the destination's `on_enter` changes.
243
275
 
244
276
  ### `enter_interaction:`
245
277
 
246
- Run an input-collecting interaction when a card is dropped **into** this column from another column — for entries that need more than a membership flip (a reason, a mail, an audit entry).
278
+ Run an input-collecting interaction when a card is dropped **into** this column from another column, for entries that need more than a membership flip (a reason, a mail, an audit entry).
247
279
 
248
280
  ```ruby
249
281
  column :lost, scope: -> { where(status: "lost") }, enter_interaction: MarkLostInteraction
@@ -260,12 +292,12 @@ class MarkLostInteraction < ResourceInteraction
260
292
  end
261
293
  ```
262
294
 
263
- - **Auto-registered as a HIDDEN record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`) — unique by construction, so two columns can reuse the same interaction class. No button on show/table/grid; reachable only by dropping. **No policy method of its own** — authorized by `kanban_move?` (see Authorization).
264
- - **Move flow:** cross-column drop opens the interaction's form as a modal; on submit `on_enter` + interaction + repositioning commit in **one atomic transaction**. Validation failure rolls it all back (membership write included) and re-renders the modal with errors — nothing persists. Put side-effects on `deliver_later` so a rollback sends no stray mail.
265
- - **Same-column reorder = positioning only** — neither `on_enter` nor the interaction fires (both = *entering* a column).
295
+ - **Auto-registered as a HIDDEN record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), unique by construction, so two columns can reuse the same interaction class. No button on show/table/grid; reachable only by dropping. **No policy method of its own**; authorized by `kanban_move?` (see Authorization).
296
+ - **Move flow:** cross-column drop opens the interaction's form as a modal; on submit `on_enter` + interaction + repositioning commit in **one atomic transaction**. Validation failure rolls it all back (membership write included) and re-renders the modal with errors; nothing persists. Put side-effects on `deliver_later` so a rollback sends no stray mail.
297
+ - **Same-column reorder = positioning only**: neither `on_enter` nor the interaction fires (both = *entering* a column).
266
298
  - **Quick-add (`+ Add`)** applies `on_enter` + positioning post-create; the interaction is not involved.
267
299
  - **Author contract:** with both present, `on_enter` owns the membership attribute (`status`) and the interaction owns extras. If the interaction also writes membership it must set the **same** value (idempotent). With no `on_enter`, the interaction owns everything (like `:lost`).
268
- - **Limitation:** custom success *responses* (`with_redirect_response`, `with_file_response`, …) are NOT honored on the drop path — board re-renders + modal closes. Use `.with_message` for feedback.
300
+ - **Limitation:** custom success *responses* (`with_redirect_response`, `with_file_response`, …) are NOT honored on the drop path: the board re-renders and the modal closes. Use `.with_message` for feedback.
269
301
 
270
302
  ### Column actions
271
303
 
@@ -282,7 +314,7 @@ column :done, … do
282
314
  end
283
315
  ```
284
316
 
285
- `on: :all` — passes every record in the column scope. `on: :visible` — passes only the currently rendered subset (respects `per_column`).
317
+ `on: :all` passes every record in the column scope. `on: :visible` passes only the currently rendered subset (respects `per_column`).
286
318
 
287
319
  ---
288
320
 
@@ -306,9 +338,9 @@ class TaskPolicy < ResourcePolicy
306
338
  end
307
339
  ```
308
340
 
309
- When `kanban_move?` returns `false`, the board renders read-only — no drag handles, no drop zones.
341
+ When `kanban_move?` returns `false`, the board renders read-only, with no drag handles or drop zones.
310
342
 
311
- **`kanban_move?` is the ONLY move authorization** — plain moves and `enter_interaction:` columns alike (the interaction has no policy method of its own). To gate a *specific* transition, read the destination (and source) column from the authorization context via the optional `kanban_to` / `kanban_from` policy readers (the `Column` objects; `nil` for every non-move check):
343
+ **`kanban_move?` is the ONLY move authorization**, plain moves and `enter_interaction:` columns alike (the interaction has no policy method of its own). To gate a *specific* transition, read the destination (and source) column from the authorization context via the optional `kanban_to` / `kanban_from` policy readers (the `Column` objects; `nil` for every non-move check):
312
344
 
313
345
  ```ruby
314
346
  def kanban_move?
@@ -317,17 +349,17 @@ def kanban_move?
317
349
  end
318
350
  ```
319
351
 
320
- Rules take no positional args in ActionPolicy — the columns arrive as declared optional context (`authorize :kanban_from/:kanban_to, optional: true` on the base policy), supplied by the controller on the move check.
352
+ Rules take no positional args in ActionPolicy; the columns arrive as declared optional context (`authorize :kanban_from/:kanban_to, optional: true` on the base policy), supplied by the controller on the move check.
321
353
 
322
354
  ### Move authorization flow
323
355
 
324
356
  1. Record loaded via current `relation_scope` (same as index).
325
- 2. `kanban_move?` checked (with `kanban_from`/`kanban_to` in context) — HTTP 403 on failure. This is the sole authorization; an `enter_interaction` rides on it.
326
- 3. Column `accepts:` / `locked:` checked — HTTP 422 + card snap-back on failure.
327
- 4. `wip:` limit checked for cross-column moves — HTTP 422 on failure.
328
- 5. `on_enter` fires + record repositioned, all in a transaction.
357
+ 2. `kanban_move?` checked (with `kanban_from`/`kanban_to` in context), HTTP 403 on failure. This is the sole authorization; an `enter_interaction` rides on it.
358
+ 3. Destination `accepts:` and source `locked:` checked: HTTP 422 + card snap-back on failure.
359
+ 4. `wip:` limit checked for cross-column moves, HTTP 422 on failure.
360
+ 5. Source `on_exit`, then destination `on_enter`, fire + record repositioned, all in a transaction.
329
361
 
330
- On a 422 rejection (steps 3–4) the response re-renders the source column (snap-back) **and** appends a dismissable warning toast naming the reason (e.g. `“Pending” is at its WIP limit (5).`) to the board's `#kanban-flash` region — so the snap-back is never silent. The toast renders the shared `plutonium/toast` partial directly (not via `flash`), so a stale undisplayed flash can't leak into the turbo-stream response.
362
+ On a 422 rejection (steps 3–4) the response re-renders the source column (snap-back) **and** appends a dismissable warning toast naming the reason (e.g. `“Pending” is at its WIP limit (5).`) to the board's `#kanban-flash` region, so the snap-back is never silent. The toast renders the shared `plutonium/toast` partial directly (not via `flash`), so a stale undisplayed flash can't leak into the turbo-stream response.
331
363
 
332
364
  ### No permitted-attributes gate
333
365
 
@@ -337,7 +369,7 @@ Moves do not pass through `permitted_attributes_for_update`. `on_enter` is trust
337
369
 
338
370
  The `+ Add` button (column `add: true`) only renders when the policy's `create?` is true. The opened form is the standard new-resource form.
339
371
 
340
- The record is created normally, **then** the column's `on_enter` + positioning are applied to the **saved** record (it lands in the clicked column, appended to the bottom) — `on_enter` runs against a real record, exactly as on a drag. **Your grouping column must have a default** (DB or model), because `on_enter` runs after save; a `NOT NULL` grouping column with no default fails quick-add create. A raising `on_enter` keeps the created record in its default column and toasts the error (the create is not rolled back).
372
+ The record is created normally, **then** the column's `on_enter` + positioning are applied to the **saved** record (it lands in the clicked column, appended to the bottom), so `on_enter` runs against a real record, exactly as on a drag. **Your grouping column must have a default** (DB or model), because `on_enter` runs after save; a `NOT NULL` grouping column with no default fails quick-add create. A raising `on_enter` keeps the created record in its default column and toasts the error (the create is not rolled back).
341
373
 
342
374
  ---
343
375
 
@@ -387,6 +419,6 @@ end
387
419
 
388
420
  ## Related skills
389
421
 
390
- - [[plutonium-resource]] — Definition layer, `grid_fields`, index views, actions
391
- - [[plutonium-behavior]] — Policy methods, `kanban_move?`, interactions
392
- - [[plutonium-ui]] — Custom Phlex components for card rendering
422
+ - [[plutonium-resource]]: Definition layer, `grid_fields`, index views, actions
423
+ - [[plutonium-behavior]]: Policy methods, `kanban_move?`, interactions
424
+ - [[plutonium-ui]]: Custom Phlex components for card rendering