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
@@ -5,7 +5,7 @@
5
5
  Every drag-and-drop move is authorized through the `kanban_move?` method on the resource's policy. The default implementation delegates to `update?`:
6
6
 
7
7
  ```ruby
8
- # Plutonium::Resource::Policy — built-in default
8
+ # Plutonium::Resource::Policy (built-in default)
9
9
  def kanban_move?
10
10
  update?
11
11
  end
@@ -29,7 +29,7 @@ end
29
29
 
30
30
  ## Gating a specific transition (`from` / `to` context)
31
31
 
32
- `kanban_move?` is the **single** authorization for every move — plain moves and `enter_interaction:` columns alike. To gate a *specific* transition, read the source and destination columns from the authorization context. They are exposed as the optional `kanban_from` / `kanban_to` policy readers (the `Plutonium::Kanban::Column` objects), and are `nil` for every non-move authorization:
32
+ `kanban_move?` is the **single** authorization for every move (plain moves and `enter_interaction:` columns alike). To gate a *specific* transition, read the source and destination columns from the authorization context. They are exposed as the optional `kanban_from` / `kanban_to` policy readers (the `Plutonium::Kanban::Column` objects), and are `nil` for every non-move authorization:
33
33
 
34
34
  ```ruby
35
35
  class DealPolicy < ResourcePolicy
@@ -42,28 +42,28 @@ class DealPolicy < ResourcePolicy
42
42
  end
43
43
  ```
44
44
 
45
- Rules take no positional arguments in ActionPolicy — the columns arrive via context, which the controller supplies on the `kanban_move?` check (`context: { kanban_from:, kanban_to: }`). This replaces per-column policy methods: an `enter_interaction:` column is authorized by `kanban_move?` too, so there is no separate `mark_lost?`-style predicate to define.
45
+ Rules take no positional arguments in ActionPolicy; the columns arrive via context, which the controller supplies on the `kanban_move?` check (`context: { kanban_from:, kanban_to: }`). This replaces per-column policy methods: an `enter_interaction:` column is authorized by `kanban_move?` too, so there is no separate `mark_lost?`-style predicate to define.
46
46
 
47
47
  Both `kanban_from` and `kanban_to` are **trustworthy** to authorize on. `to` is where the card ends up; and although `from_column` arrives from the client, the move handler **verifies the record actually resides in the claimed source column** before it proceeds (a mismatch snaps the drag back), so a spoofed or stale `from` can never drive a move past a `kanban_from`-based rule.
48
48
 
49
49
  ## Read-only board
50
50
 
51
- When `kanban_move?` returns `false` for the current user, the board is rendered read-only. Cards are displayed but dragging is disabled — no drag handles appear and the Stimulus controller does not register drop zones.
51
+ When `kanban_move?` returns `false` for the current user, the board is rendered read-only. Cards are displayed but dragging is disabled: no drag handles appear and the Stimulus controller does not register drop zones.
52
52
 
53
53
  ## Authorization flow on a move
54
54
 
55
55
  When a card is dropped, the server:
56
56
 
57
57
  1. Finds the record within the current authorized scope (the same policy `relation_scope` used by the index action).
58
- 2. Calls `authorize_current!(record, to: :kanban_move?, context: { kanban_from:, kanban_to: })` — the single authorization for the move (an `enter_interaction:` column rides on this same check, with no policy method of its own). A `false` result halts the action with HTTP 403.
59
- 3. Verifies the record actually resides in the claimed source column (`from_column` is client-supplied). A mismatch responds 422 and snaps the card back — this is what makes `kanban_from` safe to authorize on.
60
- 4. Validates the drop against the destination column's `accepts:` policy and `locked:` flag. A rejected drop responds with HTTP 422 and re-renders the source column (the Stimulus controller snaps the card back).
58
+ 2. Calls `authorize_current!(record, to: :kanban_move?, context: { kanban_from:, kanban_to: })`: the single authorization for the move (an `enter_interaction:` column rides on this same check, with no policy method of its own). A `false` result halts the action with HTTP 403.
59
+ 3. Verifies the record actually resides in the claimed source column (`from_column` is client-supplied). A mismatch responds 422 and snaps the card back; this is what makes `kanban_from` safe to authorize on.
60
+ 4. Validates the drop against the destination column's `accepts:` policy and the source column's `locked:` flag (a locked column lets no card leave). A rejected drop responds with HTTP 422 and re-renders the source column (the Stimulus controller snaps the card back).
61
61
  5. Enforces the destination column's `wip:` limit (cross-column moves only). Exceeding the WIP cap also responds 422.
62
- 6. Calls `on_enter` and repositions the record inside a transaction.
62
+ 6. Calls the source column's `on_exit`, then the destination's `on_enter`, and repositions the record, all inside a transaction.
63
63
 
64
64
  ## No permitted attributes for moves
65
65
 
66
- Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `on_enter` callback is author code that runs with full model access — it is the responsibility of the `on_enter` implementation to assign only the attributes appropriate for a column transition. This is intentional: the callback is trusted Ruby, not user-supplied form data.
66
+ Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `on_enter` callback is author code that runs with full model access; it is the responsibility of the `on_enter` implementation to assign only the attributes appropriate for a column transition. This is intentional: the callback is trusted Ruby, not user-supplied form data.
67
67
 
68
68
  ## Column-level drop policies
69
69
 
@@ -1,7 +1,7 @@
1
1
  # Kanban DSL Reference
2
2
 
3
3
  ::: warning Experimental
4
- Kanban boards are experimental — the DSL and behavior may change in a future release.
4
+ Kanban boards are experimental: the DSL and behavior may change in a future release.
5
5
  :::
6
6
 
7
7
  Complete reference for the `kanban do…end` block declared inside a resource Definition.
@@ -51,7 +51,7 @@ Controls how card positions are persisted after a drag-and-drop. Three modes:
51
51
 
52
52
  **Inherited from the definition.** `position_on` is the same verb outside `kanban do…end`, where it makes the resource's [table and grid drag-reorderable](/reference/resource/positioning). A board with no `position_on` of its own uses the **definition's**, falling back to the historic default (`:position`, Mode A). Declaring it inside the board overrides that. Resolution is lazy, so declaration order in the class body does not matter.
53
53
 
54
- #### Mode A — delegate to `Plutonium::Positioning::Model` (default)
54
+ #### Mode A: delegate to `Plutonium::Positioning::Model` (default)
55
55
 
56
56
  ```ruby
57
57
  # Default: uses :position attribute
@@ -64,30 +64,30 @@ position_on :sort_order
64
64
  Requires the model to:
65
65
  1. `include Plutonium::Positioning::Model`
66
66
  2. Call `positioned_on :position, scope: :grouping_attribute`
67
- 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)
67
+ 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)
68
68
 
69
69
  On drop, calls `record.reposition!(prev_record:, next_record:)` which computes the decimal midpoint and updates the record.
70
70
 
71
- #### Mode B — BYO block
71
+ #### Mode B: BYO block
72
72
 
73
73
  ```ruby
74
74
  position_on :sort_order do |move|
75
75
  # move is a Plutonium::Positioning::Move value object
76
76
  # (also reachable as Plutonium::Kanban::Positioning::Move):
77
- # move.record — the dropped ActiveRecord record
78
- # move.column — destination column key (Symbol)
79
- # move.prev — record immediately before the insertion slot, or nil
80
- # move.next — record immediately after the insertion slot, or nil
81
- # move.index — 0-based insertion index within the destination column
77
+ # move.record : the dropped ActiveRecord record
78
+ # move.column : destination column key (Symbol)
79
+ # move.prev : record immediately before the insertion slot, or nil
80
+ # move.next : record immediately after the insertion slot, or nil
81
+ # move.index : 0-based insertion index within the destination column
82
82
  move.record.update!(sort_order: compute_position(move.prev, move.next))
83
83
  end
84
84
  ```
85
85
 
86
- The block is evaluated via `call` (not `instance_exec`) — it is a plain Ruby proc/lambda.
86
+ The block is evaluated via `call` (not `instance_exec`); it is a plain Ruby proc/lambda.
87
87
 
88
88
  Plutonium still orders column cards by `sort_order` (the first argument); your block is responsible only for persisting the new value.
89
89
 
90
- #### Mode C — disabled
90
+ #### Mode C: disabled
91
91
 
92
92
  ```ruby
93
93
  position_on false
@@ -114,9 +114,9 @@ Enables ActionCable broadcasting after every successful move. After a drop, Plut
114
114
  - Tenant-scoped portals use the entity's Global ID parameter as the second segment.
115
115
  - Portals without entity scoping use the literal string `"global"`.
116
116
 
117
- 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.
117
+ 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.
118
118
 
119
- 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).
119
+ 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).
120
120
 
121
121
  Default: `false`.
122
122
 
@@ -141,14 +141,14 @@ show_in :modal # open a card's show page in a centered modal dialog
141
141
  show_in :page # navigate the whole page to the show route
142
142
  ```
143
143
 
144
- Overrides — **for this board** — where clicking a card opens the record's show page:
144
+ Overrides, **for this board**, where clicking a card opens the record's show page:
145
145
 
146
- - `: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`.)
147
- - `:page` — the card's show link targets `_top`, navigating the whole page to the show route.
146
+ - `: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`.)
147
+ - `:page`: the card's show link targets `_top`, navigating the whole page to the show route.
148
148
 
149
149
  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).
150
150
 
151
- 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.
151
+ 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.
152
152
 
153
153
  An unknown mode raises `ArgumentError`.
154
154
 
@@ -168,7 +168,7 @@ Overrides the slot layout for every kanban card on this board, using the same sl
168
168
 
169
169
  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.
170
170
 
171
- The `meta` slot renders each field as a colored badge, and formats values by type before badging: a `has_cents` field renders as currency, a `belongs_to` association renders as its label (not an object inspect), and everything else is humanized — with status-like enums (`active`, `pending`, `published`…) resolving to a semantic color. The badge color is deterministic per value, so a given status is the same color on every card.
171
+ The `meta` slot renders each field as a colored badge, and formats values by type before badging: a `has_cents` field renders as currency, a `belongs_to` association renders as its label (not an object inspect), and everything else is humanized, with status-like enums (`active`, `pending`, `published`…) resolving to a semantic color. The badge color is deterministic per value, so a given status is the same color on every card.
172
172
 
173
173
  ---
174
174
 
@@ -203,12 +203,12 @@ end
203
203
  | `color:` | Symbol or String | `nil` | Header color dot. Named colors: `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`. Raw CSS string also accepted |
204
204
  | `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") }` |
205
205
  | `on_enter:` | 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_enter` returns the controller saves it automatically. |
206
- | `on_exit:` | Symbol or Proc | `nil` | The source-side counterpart to `on_enter:`, fired when a card **leaves** this column on a cross-column move. Same dispatch (**Symbol** → `record.public_send`; **Proc** → 1-arg lambda, `self` = view context). Runs **before** the destination's `on_enter`, inside the same move transaction, so it sees the pre-move state and rolls back if the move fails. Use it for source-tied side effects the destination can't own (stop a timer, release a slot). Fires only on a drag-move through `kanban_move` — not on destroy, a programmatic status change, or quick-add. Skipped on same-column reorders. |
206
+ | `on_exit:` | Symbol or Proc | `nil` | The source-side counterpart to `on_enter:`, fired when a card **leaves** this column on a cross-column move. Same dispatch (**Symbol** → `record.public_send`; **Proc** → 1-arg lambda, `self` = view context). Runs **before** the destination's `on_enter`, inside the same move transaction, so it sees the pre-move state and rolls back if the move fails. Use it for source-tied side effects the destination can't own (stop a timer, release a slot). Fires only on a drag-move through `kanban_move`, not on destroy, a programmatic status change, or quick-add. Skipped on same-column reorders. Use a model callback instead only when other save paths (edit form, API, import) must be covered too, and then drop the `on_exit:` so the work isn't done twice. |
207
207
  | `enter_interaction:` | Class | `nil` | A **record-scoped** interaction class (declares `attribute :resource`) run when a card is dropped **into** this column from another column. Opens the interaction's form as a modal to collect input, then commits `on_enter` + the interaction + repositioning atomically. Auto-registered as a hidden record action under a column-scoped key (`:<column>_enter_interaction`), authorized by `kanban_move?` (no policy method of its own). See [enter_interaction](#drop-interaction) below |
208
208
  | `role:` | `:backlog`, `:done`, `:lost` | `nil` | Applies a preset (see below) |
209
209
  | `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. |
210
210
  | `add:` | Boolean | `false` | Show a `+ Add` quick-add button |
211
- | `accepts:` | `true`, `false`, or Array | `true` | Drop policy — **structural topology only**. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. This is client-hintable: the value is emitted as `data-kanban-accepts` so the drag UI greys out disallowed source columns before the drop. **Record- or user-conditional rules do not belong here** — put them in `kanban_move?`, which sees the record and the `from`/`to` columns (see [Authorization](./authorization)). Passing a `Proc` raises `ArgumentError`. |
211
+ | `accepts:` | `true`, `false`, or Array | `true` | Drop policy: **structural topology only**. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. This is client-hintable: the value is emitted as `data-kanban-accepts` so the drag UI greys out disallowed source columns before the drop. **Record- or user-conditional rules do not belong here**. Put them in `kanban_move?`, which sees the record and the `from`/`to` columns (see [Authorization](./authorization)). Passing a `Proc` raises `ArgumentError`. |
212
212
  | `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
213
213
  | `wip:` | Integer | `nil` | WIP limit. Reject cross-column drops when `dest_count + 1 > wip`. Has no effect on same-column reordering |
214
214
 
@@ -219,7 +219,7 @@ The old names are **deprecated aliases**:
219
219
  - In **development and test** they raise an `ArgumentError` so the rename is caught before release.
220
220
  - In **deployed environments** (production, staging) they log a deprecation warning and map onto the new option, so an in-flight deployment keeps working across the upgrade.
221
221
 
222
- If both the old and new name are given, the new one wins. Update your definitions to `on_enter:` / `enter_interaction:` at your earliest convenience — a future release will drop the aliases entirely.
222
+ If both the old and new name are given, the new one wins. Update your definitions to `on_enter:` / `enter_interaction:` at your earliest convenience; a future release will drop the aliases entirely.
223
223
  :::
224
224
 
225
225
  ### Role presets
@@ -230,7 +230,7 @@ If both the old and new name are given, the new one wins. Update your definition
230
230
  | `:done` | `color: :green, collapsed: true` |
231
231
  | `:lost` | `color: :red, collapsed: true` |
232
232
 
233
- `:done` and `:lost` are the two terminal roles — both collapsed by default, the
233
+ `:done` and `:lost` are the two terminal roles, both collapsed by default, the
234
234
  colour signalling the outcome (`:done` = positive close, `:lost` = negative
235
235
  close). Use them as the won/lost pair in pipelines (leads, deals, tickets).
236
236
 
@@ -246,13 +246,13 @@ column :lost,
246
246
 
247
247
  Runs an authorization-aware, input-collecting interaction when a card is dropped **into** this column from another column.
248
248
 
249
- - **Must be a record-scoped interaction** — the class declares `attribute :resource` (singular) and acts on the one dropped card. A `resources`-plural (bulk) interaction is not valid here; that shape is for [column actions](#column-actions).
250
- - **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 without colliding. Hidden = it does not render as an action button on the show page, table rows, or grid cards; it is reachable only by a drop.
251
- - **Authorized by `kanban_move?` alone** — the interaction has **no policy method of its own**. The move (and therefore the interaction) is gated by the single `kanban_move?` predicate, which can gate this specific transition via its `to` column context (e.g. `def kanban_move? = kanban_to&.key == :lost ? user.manager? : super`). See [Authorization](./authorization).
252
- - **Move flow only.** Dropping cross-column opens the interaction's form as a modal; on submit `on_enter` + the interaction + repositioning commit in **one atomic transaction**. Validation failure rolls the whole transaction back (membership write included) and re-renders the modal with errors — nothing persists. Same-column reorders run positioning only (neither `on_enter` nor the interaction fires).
253
- - **Quick-add (`+ Add`)** applies `on_enter` + positioning **post-create** — the record is created first (needs a grouping-column default), then `on_enter` places it in the column; the `enter_interaction` is not involved.
254
- - **Author contract** — when a column declares both, `on_enter` owns the membership attribute (e.g. `status`) and the interaction owns the extras (reason, mail, audit). If the interaction also writes the membership attribute it must set the same value `on_enter` does (idempotent). With no `on_enter`, the interaction owns everything.
255
- - **Success response limitation** — the interaction's success **message** (`.with_message`) surfaces as a toast, but a custom success *response* (`with_redirect_response`, `with_file_response`, …) is **not** honored on the drop path; the board just re-renders and closes the modal.
249
+ - **Must be a record-scoped interaction.** The class declares `attribute :resource` (singular) and acts on the one dropped card. A `resources`-plural (bulk) interaction is not valid here; that shape is for [column actions](#column-actions).
250
+ - **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 without colliding. Hidden = it does not render as an action button on the show page, table rows, or grid cards; it is reachable only by a drop.
251
+ - **Authorized by `kanban_move?` alone.** The interaction has **no policy method of its own**. The move (and therefore the interaction) is gated by the single `kanban_move?` predicate, which can gate this specific transition via its `to` column context (e.g. `def kanban_move? = kanban_to&.key == :lost ? user.manager? : super`). See [Authorization](./authorization).
252
+ - **Move flow only.** Dropping cross-column opens the interaction's form as a modal; on submit `on_enter` + the interaction + repositioning commit in **one atomic transaction**. Validation failure rolls the whole transaction back (membership write included) and re-renders the modal with errors; nothing persists. Same-column reorders run positioning only (neither `on_enter` nor the interaction fires).
253
+ - **Quick-add (`+ Add`)** applies `on_enter` + positioning **post-create**: the record is created first (needs a grouping-column default), then `on_enter` places it in the column; the `enter_interaction` is not involved.
254
+ - **Author contract.** When a column declares both, `on_enter` owns the membership attribute (e.g. `status`) and the interaction owns the extras (reason, mail, audit). If the interaction also writes the membership attribute it must set the same value `on_enter` does (idempotent). With no `on_enter`, the interaction owns everything.
255
+ - **Success response limitation.** The interaction's success **message** (`.with_message`) surfaces as a toast, but a custom success *response* (`with_redirect_response`, `with_file_response`, …) is **not** honored on the drop path; the board just re-renders and closes the modal.
256
256
 
257
257
  See the [guide's Interaction on drop section](/guides/kanban#interaction-on-drop) for a full worked example.
258
258
 
@@ -265,7 +265,7 @@ Use `columns do…end` when the column list depends on the current request:
265
265
  ```ruby
266
266
  kanban do
267
267
  columns do
268
- # `self` is the view context — current_user, params, and helpers all work.
268
+ # `self` is the view context: current_user, params, and helpers all work.
269
269
  current_user.visible_statuses.map do |status|
270
270
  Plutonium::Kanban::Column.new(
271
271
  :"status_#{status.id}",
@@ -299,7 +299,7 @@ class TaskDefinition < ResourceDefinition
299
299
  end
300
300
  ```
301
301
 
302
- **`enter_interaction:` is not supported on dynamic boards.** Unlike a column action, its hidden action can't be registered manually — the key is column-scoped and internal — and the static registration pass has no columns to see. A drop into a dynamic column that declares `enter_interaction:` is rejected with a snap-back (it does not crash). Use a static board when a column needs an `enter_interaction:`.
302
+ **`enter_interaction:` is not supported on dynamic boards.** Unlike a column action, its hidden action can't be registered manually: the key is column-scoped and internal, and the static registration pass has no columns to see. A drop into a dynamic column that declares `enter_interaction:` is rejected with a snap-back (it does not crash). Use a static board when a column needs an `enter_interaction:`.
303
303
  :::
304
304
 
305
305
  ---
@@ -326,7 +326,7 @@ end
326
326
 
327
327
  | Option | Type | Required | Description |
328
328
  |--------|------|----------|-------------|
329
- | `interaction:` | Class | Yes | An interaction class. Must have `attribute :resources` (plural) — it runs as a bulk action |
329
+ | `interaction:` | Class | Yes | An interaction class. Must have `attribute :resources` (plural); it runs as a bulk action |
330
330
  | `on:` | `:all` or `:visible` | No (default `:all`) | `:all` passes IDs of all column cards (ignoring `per_column`). `:visible` passes only the rendered, capped subset |
331
331
  | `label:` | String | No | Button text. Defaults to `key.to_s.humanize` |
332
332
  | `icon:` | Phlex icon class | No | Icon rendered before the label |
@@ -6,7 +6,7 @@ Reference documentation for the Plutonium kanban board feature.
6
6
 
7
7
  | Page | What it covers |
8
8
  |------|---------------|
9
- | [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL — board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
9
+ | [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL: board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
10
10
  | [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning::Model` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
11
11
  | [Authorization](/reference/kanban/authorization) | `kanban_move?` policy predicate, read-only fallback, separating move rights from edit rights |
12
12
 
@@ -1,10 +1,10 @@
1
1
  # Kanban Positioning
2
2
 
3
3
  ::: tip Positioning is not kanban-only
4
- The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder** — see [Positioning & drag-to-reorder](/reference/resource/positioning) for the index-surface half of the feature (the grip, the `reposition` endpoint, `reposition?`, and board inheritance).
4
+ The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder**; see [Positioning & drag-to-reorder](/reference/resource/positioning) for the index-surface half of the feature (the grip, the `reposition` endpoint, `reposition?`, and board inheritance).
5
5
  :::
6
6
 
7
- 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)).
7
+ 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, with 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)).
8
8
 
9
9
  ## `Plutonium::Positioning::Model` concern
10
10
 
@@ -34,7 +34,7 @@ After calling `positioned_on`, the model gets:
34
34
 
35
35
  ### Migration
36
36
 
37
- 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:
37
+ 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:
38
38
 
39
39
  ```ruby
40
40
  create_table :tasks do |t|
@@ -56,6 +56,8 @@ class AddPositionToTasks < ActiveRecord::Migration[8.1]
56
56
  end
57
57
  ```
58
58
 
59
+ For a model owned by a feature package (e.g. `Catalog::Product`), put this migration in `packages/<package>/db/migrate/`, next to the table's create migration. Each package's `db/migrate` is appended 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`, so move the file into the package afterwards.
60
+
59
61
  `t.position` accepts a custom column name and any `column` options:
60
62
 
61
63
  ```ruby
@@ -65,7 +67,7 @@ t.position :position, scale: 10 # override precision/scale
65
67
  ```
66
68
 
67
69
  ::: tip Why the helper picks `scale: 8`
68
- 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.
70
+ 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.
69
71
  :::
70
72
 
71
73
  ---
@@ -88,7 +90,7 @@ task.reposition!(prev_record: last_card, next_record: nil) # append
88
90
 
89
91
  ### Gap exhaustion (rebalancing) {#rebalancing}
90
92
 
91
- 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`.
93
+ 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`.
92
94
 
93
95
  ---
94
96
 
@@ -108,7 +110,7 @@ Task.backfill_positions!(order: :created_at)
108
110
  The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:
109
111
 
110
112
  ::: tip A board inherits the definition's `position_on`
111
- `position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end` — the board picks up the same attribute and the same mode.
113
+ `position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end`; the board picks up the same attribute and the same mode.
112
114
 
113
115
  Resolution is lazy, so a `kanban do…end` written **above** `position_on` in the class body still sees it.
114
116
 
@@ -123,7 +125,7 @@ end
123
125
  ```
124
126
  :::
125
127
 
126
- ### Mode A — delegate (default)
128
+ ### Mode A: delegate (default)
127
129
 
128
130
  ```ruby
129
131
  kanban do
@@ -135,26 +137,26 @@ end
135
137
 
136
138
  On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning::Model` and call `positioned_on`.
137
139
 
138
- ### Mode B — BYO block
140
+ ### Mode B: BYO block
139
141
 
140
142
  ```ruby
141
143
  kanban do
142
144
  position_on :sort_order do |move|
143
- # move.record — the dropped record
144
- # move.column — destination column key (Symbol)
145
- # move.prev — record immediately before the slot (or nil)
146
- # move.next — record immediately after the slot (or nil)
147
- # move.index — 0-based insertion index within the destination column
145
+ # move.record: the dropped record
146
+ # move.column: destination column key (Symbol)
147
+ # move.prev: record immediately before the slot (or nil)
148
+ # move.next: record immediately after the slot (or nil)
149
+ # move.index: 0-based insertion index within the destination column
148
150
  move.record.update!(sort_order: my_position(move.prev, move.next))
149
151
  end
150
152
  end
151
153
  ```
152
154
 
153
- 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::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`) — it is NOT `instance_exec`'d, so `self` is the proc's original binding.
155
+ 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::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`); it is NOT `instance_exec`'d, so `self` is the proc's original binding.
154
156
 
155
157
  On a table or grid the same block runs with `move.column` set to `nil`, since those surfaces have no columns. See [Mode B](/reference/resource/positioning#mode-b) for a worked `acts_as_list` example.
156
158
 
157
- ### Mode C — disabled
159
+ ### Mode C: disabled
158
160
 
159
161
  ```ruby
160
162
  kanban do