plutonium 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
  3. data/CHANGELOG.md +27 -0
  4. data/app/assets/plutonium.css +1 -1
  5. data/app/assets/plutonium.js +315 -38
  6. data/app/assets/plutonium.js.map +4 -4
  7. data/app/assets/plutonium.min.js +31 -31
  8. data/app/assets/plutonium.min.js.map +4 -4
  9. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  10. data/app/views/resource/kanban_move_form.html.erb +1 -0
  11. data/config/brakeman.ignore +2 -2
  12. data/docs/.vitepress/config.ts +21 -1
  13. data/docs/.vitepress/sync-skills.mjs +45 -0
  14. data/docs/ai.md +99 -0
  15. data/docs/guides/kanban.md +128 -18
  16. data/docs/reference/kanban/authorization.md +25 -5
  17. data/docs/reference/kanban/dsl.md +49 -8
  18. data/docs/reference/kanban/index.md +3 -3
  19. data/docs/reference/kanban/positioning.md +1 -1
  20. data/docs/reference/resource/definition.md +10 -1
  21. data/docs/reference/resource/model.md +26 -0
  22. data/docs/reference/ui/forms.md +41 -0
  23. data/docs/reference/wizard/dsl.md +5 -0
  24. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  25. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  26. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  27. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  28. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  29. data/lib/plutonium/action/base.rb +8 -0
  30. data/lib/plutonium/configuration.rb +12 -0
  31. data/lib/plutonium/definition/index_views.rb +16 -0
  32. data/lib/plutonium/kanban/column.rb +80 -27
  33. data/lib/plutonium/models/has_cents.rb +30 -2
  34. data/lib/plutonium/resource/controller.rb +22 -1
  35. data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
  36. data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
  37. data/lib/plutonium/resource/policy.rb +6 -0
  38. data/lib/plutonium/routing/mapper_extensions.rb +1 -0
  39. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  40. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  41. data/lib/plutonium/ui/form/base.rb +6 -0
  42. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  43. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  44. data/lib/plutonium/ui/form/components/uppy.rb +20 -2
  45. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  46. data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
  47. data/lib/plutonium/ui/form/resource.rb +12 -0
  48. data/lib/plutonium/ui/form/theme.rb +7 -0
  49. data/lib/plutonium/ui/grid/card.rb +40 -13
  50. data/lib/plutonium/ui/kanban/column.rb +111 -24
  51. data/lib/plutonium/ui/kanban/resource.rb +118 -11
  52. data/lib/plutonium/ui/layout/base.rb +1 -1
  53. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  54. data/lib/plutonium/ui/page/index.rb +1 -1
  55. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  56. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  57. data/lib/plutonium/ui/page/show.rb +7 -2
  58. data/lib/plutonium/ui/table/resource.rb +1 -1
  59. data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
  60. data/lib/plutonium/version.rb +1 -1
  61. data/package.json +5 -3
  62. data/src/css/components.css +5 -0
  63. data/src/js/controllers/currency_input_controller.js +39 -0
  64. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  65. data/src/js/controllers/kanban_controller.js +442 -55
  66. data/src/js/controllers/register_controllers.js +2 -0
  67. data/yarn.lock +674 -4
  68. metadata +14 -2
@@ -0,0 +1 @@
1
+ <%= render Plutonium::UI::Form::KanbanMove.new(@interaction) %>
@@ -0,0 +1 @@
1
+ <%= render Plutonium::UI::Page::KanbanMove.new %>
@@ -144,11 +144,11 @@
144
144
  {
145
145
  "warning_type": "Dangerous Eval",
146
146
  "warning_code": 13,
147
- "fingerprint": "ac51d516bdbc88a0a624795dad1b3efc1d2744b3f2d7be7feb67e0b5cebab763",
147
+ "fingerprint": "bdacd028907815ba0db0cf99e069d1ea7e94a6e527b7746efd97886c52b2f928",
148
148
  "check_name": "Evaluation",
149
149
  "message": "Dynamic string evaluated as code",
150
150
  "file": "lib/plutonium/models/has_cents.rb",
151
- "line": 134,
151
+ "line": 162,
152
152
  "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
153
153
  "code": "class_eval(\" # Getter method for the decimal representation of the cents value.\\n #\\n # @return [BigDecimal, nil] The decimal value or nil if cents_name is not present.\\n def #{(:\"#{cents_name.to_sym}_#{suffix}\" or cents_name.to_sym.to_s.gsub(/_cents$/, \"\").to_sym)}\\n #{cents_name.to_sym}.to_d / #{rate} if #{cents_name.to_sym}.present?\\n end\\n\\n # Setter method for the decimal representation of the cents value.\\n #\\n # @param value [Numeric, nil] The decimal value to be set.\\n def #{(:\"#{cents_name.to_sym}_#{suffix}\" or cents_name.to_sym.to_s.gsub(/_cents$/, \"\").to_sym)}=(value)\\n self.#{cents_name.to_sym} = begin\\n (BigDecimal(value.to_s) * #{rate}).to_i if value.present?\\n rescue ArgumentError\\n nil\\n end\\n end\\n\\n # Mark decimal field as invalid if cents field is not valid\\n after_validation do\\n next unless errors[#{cents_name.to_sym.inspect}].present?\\n\\n errors.add(#{(:\"#{cents_name.to_sym}_#{suffix}\" or cents_name.to_sym.to_s.gsub(/_cents$/, \"\").to_sym).inspect}, :invalid)\\n end\\n\", \"lib/plutonium/models/has_cents.rb\", 135)",
154
154
  "render_path": null,
@@ -1,5 +1,6 @@
1
1
  import { defineConfig } from "vitepress"
2
2
  import { withMermaid } from "vitepress-plugin-mermaid";
3
+ import llmstxt from "vitepress-plugin-llms";
3
4
 
4
5
  const base = "/plutonium-core/"
5
6
 
@@ -22,6 +23,24 @@ export default defineConfig(withMermaid({
22
23
  ],
23
24
  ignoreDeadLinks: 'localhostLinks',
24
25
  srcExclude: ['superpowers/**'],
26
+ vite: {
27
+ plugins: [
28
+ // Generates llms.txt, llms-full.txt, and a raw .md twin for every page.
29
+ llmstxt({
30
+ // Site base (/plutonium-core/) is appended automatically — domain must not include it.
31
+ domain: "https://radioactive-labs.github.io",
32
+ // public/ is served verbatim (skills live there); superpowers/ is internal.
33
+ // Section landing pages are Vue components with no markdown content.
34
+ ignoreFiles: [
35
+ "superpowers/**",
36
+ "public/**",
37
+ "getting-started/index.md",
38
+ "guides/index.md",
39
+ "reference/index.md",
40
+ ],
41
+ }),
42
+ ],
43
+ },
25
44
  themeConfig: {
26
45
  // https://vitepress.dev/reference/default-theme-config
27
46
  logo: "/plutonium.png",
@@ -32,7 +51,8 @@ export default defineConfig(withMermaid({
32
51
  { text: "Home", link: "/" },
33
52
  { text: "Getting Started", link: "/getting-started/" },
34
53
  { text: "Guides", link: "/guides/" },
35
- { text: "Reference", link: "/reference/" }
54
+ { text: "Reference", link: "/reference/" },
55
+ { text: "For AI Agents", link: "/ai" }
36
56
  ],
37
57
  sidebar: {
38
58
  '/getting-started/': [
@@ -0,0 +1,45 @@
1
+ // Syncs .claude/skills/*/SKILL.md into docs/public/skills/ so the docs site
2
+ // serves them as raw, crawlable markdown at /skills/<name>.md.
3
+ // Runs before `docs:dev` and `docs:build` (see package.json). Output is gitignored.
4
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"
5
+ import { join, dirname } from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..")
9
+ const skillsDir = join(root, ".claude", "skills")
10
+ const outDir = join(root, "docs", "public", "skills")
11
+
12
+ rmSync(outDir, { recursive: true, force: true })
13
+ mkdirSync(outDir, { recursive: true })
14
+
15
+ const skills = []
16
+
17
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
18
+ if (!entry.isDirectory()) continue
19
+ const source = join(skillsDir, entry.name, "SKILL.md")
20
+ if (!existsSync(source)) continue // skip dirs that aren't skills (no SKILL.md)
21
+ let content = readFileSync(source, "utf8")
22
+
23
+ const description = content.match(/^description:\s*(.+)$/m)?.[1] ?? ""
24
+
25
+ // Wiki-style [[skill-name]] cross-links become relative markdown links so
26
+ // crawlers can follow them between the published files.
27
+ content = content.replace(/\[\[([\w-]+)\]\]/g, "[$1]($1.md)")
28
+
29
+ writeFileSync(join(outDir, `${entry.name}.md`), content)
30
+ skills.push({ name: entry.name, description })
31
+ }
32
+
33
+ skills.sort((a, b) => (a.name === "plutonium" ? -1 : b.name === "plutonium" ? 1 : a.name.localeCompare(b.name)))
34
+
35
+ const index = `# Plutonium Skills
36
+
37
+ Task-focused guides for AI agents working with the [Plutonium](https://radioactive-labs.github.io/plutonium-core/) Rails RAD framework. Each file is self-contained markdown. Start with \`plutonium.md\` — it routes to the others.
38
+
39
+ These are the same skills the gem installs into projects via \`rails g pu:skills:sync\` (Claude Code loads them automatically from \`.claude/skills/\`). Any agent can fetch them directly from the URLs below.
40
+
41
+ ${skills.map((s) => `- [${s.name}](${s.name}.md) — ${s.description}`).join("\n")}
42
+ `
43
+
44
+ writeFileSync(join(outDir, "index.md"), index)
45
+ console.log(`Synced ${skills.length} skills to docs/public/skills/`)
data/docs/ai.md ADDED
@@ -0,0 +1,99 @@
1
+ ---
2
+ title: Plutonium for AI Agents
3
+ description: Machine-readable entry point for AI coding agents building Rails apps with Plutonium — install commands, conventions, generator catalog, and skill files.
4
+ sidebar: false
5
+ ---
6
+
7
+ # Plutonium for AI Agents
8
+
9
+ You are reading the agent entry point for **Plutonium**, a Rails RAD framework (Ruby gem) that provides resource-oriented architecture: automatic CRUD, authorization policies, declarative UI definitions, authentication, and multi-tenancy. If a user asked you to build something with Plutonium, this page gives you the golden path and where to read next.
10
+
11
+ ## Machine-readable resources
12
+
13
+ | Resource | URL |
14
+ |---|---|
15
+ | Docs index (llms.txt) | `https://radioactive-labs.github.io/plutonium-core/llms.txt` |
16
+ | Full docs, one file | `https://radioactive-labs.github.io/plutonium-core/llms-full.txt` |
17
+ | Agent skills index | `https://radioactive-labs.github.io/plutonium-core/skills/index.md` |
18
+ | Router skill (start here) | `https://radioactive-labs.github.io/plutonium-core/skills/plutonium.md` |
19
+
20
+ Every docs page also has a raw markdown twin: append `.md` to its URL (e.g. `/guides/multi-tenancy.md`).
21
+
22
+ **Prefer the skills over the raw docs.** They are task-focused, deduplicated, and encode the mistakes agents actually make. The router skill maps "about to do X" to the right skill file. If you are Claude Code, run `rails g pu:skills:sync` after installing the gem to install them into `.claude/skills/` so they load automatically.
23
+
24
+ ## Rules that prevent expensive mistakes
25
+
26
+ 1. **Plutonium is generator-driven.** Nearly every file has a `pu:*` generator. Generate, then edit — never hand-write models, definitions, policies, or packages from scratch. Hand-written files drift from conventions and break future generator runs.
27
+ 2. **Inspect before you act.** Check `Gemfile` for `plutonium`, `ls config/packages.rb`, and `ls packages/` before installing or scaffolding anything.
28
+ 3. **New app → `plutonium.rb` template. Existing app → `base.rb` template.** Never run `plutonium.rb` on an existing app; it re-runs full bootstrap and clobbers git history.
29
+ 4. **Pass flags to avoid interactive prompts**: `--dest=main_app` (or `--dest=<package>`), `--force` when re-running meta-generators, `--auth=<account>` for portals, `--skip-bundle`, `--quiet`.
30
+ 5. **Quote field arguments with special characters**: `'title:string?'`, `'price:decimal{10,2}'`.
31
+ 6. **Multi-tenancy is structural, not filtered in policies.** The portal declares `scope_to_entity`; the model provides the association path that `associated_with` resolves. Never `where(organization: ...)` in a policy — read the tenancy skill first.
32
+
33
+ ## Golden path: new application
34
+
35
+ ```bash
36
+ rails new myapp -a propshaft -j esbuild -c tailwind \
37
+ -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
38
+ cd myapp
39
+ rails db:prepare
40
+ ```
41
+
42
+ Then for each resource:
43
+
44
+ ```bash
45
+ rails g pu:res:scaffold Post 'title:string' 'body:text?' user:references --dest=main_app
46
+ rails db:prepare
47
+ rails g pu:res:conn Post --dest=<portal_package>
48
+ ```
49
+
50
+ ## Golden path: existing application
51
+
52
+ ```bash
53
+ bin/rails app:template \
54
+ LOCATION=https://radioactive-labs.github.io/plutonium-core/templates/base.rb
55
+ ```
56
+
57
+ Or manually: add `gem "plutonium"` to the Gemfile, `bundle install`, then `rails g pu:core:install`.
58
+
59
+ ## Architecture in one table
60
+
61
+ A **resource** is four cooperating layers. Plutonium auto-fills defaults from the model; you only declare overrides.
62
+
63
+ | Layer | File | Purpose |
64
+ |---|---|---|
65
+ | Model | `app/models/post.rb` | Data, validations, associations |
66
+ | Definition | `app/definitions/post_definition.rb` | UI — fields, filters, actions |
67
+ | Policy | `app/policies/post_policy.rb` | Authorization |
68
+ | Controller | `app/controllers/posts_controller.rb` | Rarely edited — use hooks |
69
+ | Interaction (optional) | `app/interactions/publish_post_interaction.rb` | Business logic for custom actions |
70
+
71
+ Resources live in the main app or in **feature packages**; users access them through **portal packages** (Rails engines with their own auth and optional tenant scoping).
72
+
73
+ ## Generator catalog
74
+
75
+ Discover all generators with `rails g --help | grep pu:`. The most used:
76
+
77
+ | Generator | Purpose |
78
+ |---|---|
79
+ | `pu:core:install` | Initial Plutonium setup |
80
+ | `pu:res:scaffold NAME field:type ... --dest=` | New resource (model, migration, policy, definition) |
81
+ | `pu:res:conn RESOURCE --dest=PORTAL` | Connect a resource to a portal |
82
+ | `pu:pkg:package NAME` | Feature package |
83
+ | `pu:pkg:portal NAME --auth=...` | Portal package |
84
+ | `pu:rodauth:install` / `pu:rodauth:account NAME` | Authentication |
85
+ | `pu:saas:setup --user ... --entity ...` | Full SaaS bootstrap: user + tenant + membership + portal |
86
+ | `pu:test:install` / `pu:test:scaffold NAME` | Testing scaffolds |
87
+ | `pu:skills:sync` | Install the agent skill files into the project |
88
+
89
+ ## Verify your work
90
+
91
+ ```bash
92
+ rails runner "puts Plutonium::VERSION" # gem installed and loaded
93
+ rails db:prepare # migrations applied
94
+ bin/dev # boot; visit the portal route
95
+ ```
96
+
97
+ ## For humans reading this
98
+
99
+ This page exists so AI coding agents can bootstrap correctly on the first try. Point your agent at it, or just say: *"Read https://radioactive-labs.github.io/plutonium-core/ai.md before starting."* The [Getting Started guide](/getting-started/) covers the same ground for people.
@@ -12,7 +12,7 @@ Turn any resource index into a drag-and-drop kanban board — columns, WIP limit
12
12
 
13
13
  - Drag cards between columns; the server persists the column change and the position within the column.
14
14
  - Decimal fractional positioning — cards always land exactly where you drop them without renumbering.
15
- - Per-column `+ Add` button opens the resource's normal new form, pre-seeded for that column.
15
+ - Per-column `+ Add` button opens the resource's normal new form; the new card is placed in that column (`on_enter` + positioning applied post-create).
16
16
  - Column actions run an interaction against all (or visible) cards in a column.
17
17
  - WIP limits, locked columns, and cross-column drop restrictions enforced server-side.
18
18
  - Opt-in realtime: every connected viewer sees the same board state after any move.
@@ -66,17 +66,17 @@ class TaskDefinition < ResourceDefinition
66
66
 
67
67
  column :todo,
68
68
  scope: -> { where(status: "todo") },
69
- on_drop: ->(r) { r.update!(status: "todo") },
69
+ on_enter: ->(r) { r.update!(status: "todo") },
70
70
  role: :backlog # shorthand for add: true
71
71
 
72
72
  column :doing,
73
73
  scope: -> { where(status: "doing") },
74
- on_drop: ->(r) { r.update!(status: "doing") },
74
+ on_enter: ->(r) { r.update!(status: "doing") },
75
75
  wip: 3
76
76
 
77
77
  column :done,
78
78
  scope: -> { where(status: "done") },
79
- on_drop: :mark_done!, # Symbol → record.mark_done!
79
+ on_enter: :mark_done!, # Symbol → record.mark_done!
80
80
  accepts: [:doing], # only cards from :doing can land here
81
81
  role: :done do # shorthand for color: :green, collapsed: true
82
82
  action :archive_all,
@@ -116,7 +116,7 @@ If a move is refused server-side — the destination is at its `wip:` limit, its
116
116
 
117
117
  ![A warning toast reading “Pending” is at its WIP limit (5) after a rejected drop](/images/guides/kanban-wip-toast.png)
118
118
 
119
- The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check — most commonly a WIP-full column or a per-card `accepts:` Proc.
119
+ The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check — most commonly a WIP-full column or a `kanban_move?` denial.
120
120
 
121
121
  ### Opening a card
122
122
 
@@ -149,15 +149,15 @@ class KitchenSinkDefinition < ResourceDefinition
149
149
  kanban do
150
150
  column :active, label: "Active", role: :backlog,
151
151
  scope: -> { where(status: :active) },
152
- on_drop: ->(ks) { ks.status = :active }
152
+ on_enter: ->(ks) { ks.status = :active }
153
153
 
154
154
  column :pending, label: "Pending", color: :yellow, wip: 5,
155
155
  scope: -> { where(status: :pending) },
156
- on_drop: ->(ks) { ks.status = :pending }
156
+ on_enter: ->(ks) { ks.status = :pending }
157
157
 
158
158
  column :archived, label: "Archived", role: :done,
159
159
  scope: -> { where(status: :archived) },
160
- on_drop: ->(ks) { ks.status = :archived }
160
+ on_enter: ->(ks) { ks.status = :archived }
161
161
 
162
162
  per_column 10
163
163
  end
@@ -168,7 +168,7 @@ Key points:
168
168
  - `role: :backlog` enables the `+ Add` button (equivalent to `add: true`).
169
169
  - `wip: 5` caps the Pending column; a cross-column drop that would push it past 5 is rejected server-side.
170
170
  - `role: :done` collapses the Archived column by default and shows a green header dot.
171
- - `on_drop` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_drop` returns — you do not need to call `update!` explicitly.
171
+ - `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns — you do not need to call `update!` explicitly.
172
172
 
173
173
  ---
174
174
 
@@ -184,7 +184,7 @@ kanban do
184
184
  label: "Product Backlog", # default: key.to_s.titleize
185
185
  color: :blue, # dot color in the column header
186
186
  scope: -> { where(stage: 0) }, # 0-arg lambda evaluated on the relation
187
- on_drop: ->(r) { r.update!(stage: 0) }
187
+ on_enter: ->(r) { r.update!(stage: 0) }
188
188
  end
189
189
  ```
190
190
 
@@ -201,7 +201,7 @@ kanban do
201
201
  :"project_#{project.id}",
202
202
  label: project.name,
203
203
  scope: -> { where(project_id: project.id) },
204
- on_drop: ->(r) { r.update!(project_id: project.id) }
204
+ on_enter: ->(r) { r.update!(project_id: project.id) }
205
205
  )
206
206
  end
207
207
  end
@@ -227,6 +227,8 @@ class TaskDefinition < ResourceDefinition
227
227
  end
228
228
  end
229
229
  ```
230
+
231
+ Note that **`enter_interaction:` is not supported on dynamic boards** — its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
230
232
  :::
231
233
 
232
234
  ### Column options
@@ -236,11 +238,13 @@ end
236
238
  | `label:` | String | `key.to_s.titleize` | Column header text |
237
239
  | `color:` | Symbol or String | `nil` | Dot color in the column header — `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
238
240
  | `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
239
- | `on_drop:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
240
- | `role:` | `:backlog`, `:done` | `nil` | Preset shorthand (see below) |
241
+ | `on_enter:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
242
+ | `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter:` — called when a card **leaves** this column on a cross-column move, before the destination's `on_enter`, in the same transaction. For source-tied side effects (stop a timer, release a slot). Drag-moves only (not destroy/programmatic/quick-add); skipped on same-column reorders |
243
+ | `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column — opens a modal to collect input, then commits atomically. See [Interaction on drop](#interaction-on-drop) |
244
+ | `role:` | `:backlog`, `:done`, `:lost` | `nil` | Preset shorthand (see below) |
241
245
  | `collapsed:` | Boolean | `false` | Start collapsed |
242
246
  | `add:` | Boolean | `false` | Show `+ Add` quick-add button |
243
- | `accepts:` | `true`, `false`, Array of keys, or Proc | `true` | Which drops are accepted. `true` = all, `false` = none, `[:doing]` = only from `:doing`. A 1-arg Proc `->(record) { … }` is evaluated **per-card on the server** at drop time and returns a boolean (e.g. `->(task) { task.status == "doing" }`) |
247
+ | `accepts:` | `true`, `false`, or Array of keys | `true` | Which source columns may drop here (structural, client-hintable). `true` = all, `false` = none, `[:doing]` = only from `:doing`. Record/user conditions go in `kanban_move?` instead (it sees the record and `from`/`to`); a `Proc` here raises |
244
248
  | `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
245
249
  | `wip:` | Integer | `nil` | Work-in-progress limit. Cross-column drops that would exceed this count are rejected |
246
250
 
@@ -250,6 +254,11 @@ end
250
254
  |------|---------------|
251
255
  | `:backlog` | `add: true` |
252
256
  | `:done` | `color: :green, collapsed: true` |
257
+ | `:lost` | `color: :red, collapsed: true` |
258
+
259
+ `:done` and `:lost` are the two terminal roles (both collapsed by default) — the
260
+ won/lost pair for pipelines like leads, deals, or tickets; the colour signals the
261
+ outcome.
253
262
 
254
263
  Explicitly provided options override the preset.
255
264
 
@@ -264,7 +273,7 @@ Declare actions inside a column block to run an interaction against that column'
264
273
  ```ruby
265
274
  column :done,
266
275
  scope: -> { where(status: "done") },
267
- on_drop: :mark_done! do
276
+ on_enter: :mark_done! do
268
277
 
269
278
  action :archive_all,
270
279
  interaction: ArchiveTasksInteraction, # must be a bulk interaction (has `attribute :resources`)
@@ -282,6 +291,101 @@ Column actions are rendered as buttons in the column header. They open the norma
282
291
 
283
292
  ---
284
293
 
294
+ ## Interaction on drop
295
+
296
+ A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip — a reason, a notification email, an audit entry.
297
+
298
+ ```ruby
299
+ column :lost,
300
+ scope: -> { where(status: "lost") },
301
+ enter_interaction: MarkLostInteraction
302
+ ```
303
+
304
+ `enter_interaction:` takes an **Interaction class**. It must be **record-scoped** — it declares `attribute :resource` and acts on the single dropped card. A bulk (`attribute :resources`) interaction is not valid here; that shape is for [column actions](#column-actions).
305
+
306
+ The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards — it is reachable only by dropping a card into the column.
307
+
308
+ ### The interaction
309
+
310
+ A drop interaction is an ordinary record-scoped interaction — nothing kanban-specific in the class:
311
+
312
+ ```ruby
313
+ class MarkLostInteraction < ResourceInteraction
314
+ presents label: "Mark Lost",
315
+ icon: Phlex::TablerIcons::X
316
+
317
+ attribute :resource
318
+ attribute :reason, :string
319
+
320
+ input :reason
321
+
322
+ validates :reason, presence: true
323
+
324
+ def execute
325
+ resource.update!(status: "lost", lost_reason: reason)
326
+ succeed(resource).with_message("Marked as lost")
327
+ end
328
+ end
329
+ ```
330
+
331
+ ### Authorization
332
+
333
+ The drop is authorized by the single **`kanban_move?`** predicate — the interaction has **no policy method of its own**. To gate this specific transition, branch on the destination column, which `kanban_move?` reads from its authorization context (`kanban_to`):
334
+
335
+ ```ruby
336
+ class TaskPolicy < ResourcePolicy
337
+ def kanban_move?
338
+ return update? if kanban_to&.key == :lost # who may mark a task lost
339
+ super
340
+ end
341
+ end
342
+ ```
343
+
344
+ This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition — no per-interaction predicate, no `condition:` proc. If the check fails the drop is refused and the card stays put. See [Authorization](../reference/kanban/authorization) for the full `from`/`to` context.
345
+
346
+ ### Two flows, split by intent
347
+
348
+ - **Move flow (drag a card cross-column).** Dropping into the column opens the interaction's form as a **modal** to collect input (the `reason`). On submit, the membership write (`on_enter`, if any), the interaction, and the repositioning are committed in **one atomic transaction**.
349
+ - **Quick-add (`+ Add`).** The `+ Add` button creates the record, then applies `on_enter` + positioning **post-create** (see [Quick-add](#quick-add)). The `enter_interaction` is **not** involved in quick-add.
350
+
351
+ ### Author contract: on_enter owns membership, the interaction owns extras
352
+
353
+ A column can declare `on_enter:` and `enter_interaction:` together. When it does:
354
+
355
+ - `on_enter` owns the **membership attribute** (the column's grouping value, e.g. `status`).
356
+ - `enter_interaction` owns the **extras** — the reason, the mail, the audit trail.
357
+
358
+ If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that — `on_enter` sets `status = "blocked"` and the interaction's `execute` re-asserts `status: "blocked"` while adding the reason:
359
+
360
+ ```ruby
361
+ column :blocked,
362
+ scope: -> { where(status: "blocked") },
363
+ on_enter: ->(r) { r.status = "blocked" },
364
+ enter_interaction: BlockTaskInteraction
365
+ ```
366
+
367
+ When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything — including the membership write — because there is no `on_enter` to do it.
368
+
369
+ ### Same-column drops run positioning only
370
+
371
+ Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires — both represent *entering* a column, and a same-column reorder is not an entry. Only cross-column drops trigger them.
372
+
373
+ ### Atomicity and failure
374
+
375
+ Interaction validation failure rolls the **whole transaction back** — the membership write included — and re-renders the modal with errors. The move context is preserved, so the user can fix the input and resubmit. Nothing is persisted on failure. Keep side-effects on `deliver_later` (mailers, jobs): a rolled-back failure then sends no stray mail, because the enqueue never commits.
376
+
377
+ ### Success feedback and the response limitation
378
+
379
+ On success the board's column frames re-render and the modal closes. The interaction's success **message** (`succeed(resource).with_message("Marked as lost")`) is surfaced as a toast.
380
+
381
+ ::: warning Custom success responses are not honored on the drop path
382
+ A drop interaction's custom success *response* — `with_redirect_response`, `with_file_response`, etc. — is **not** honored when it runs from a drop: the board simply re-renders and closes the modal. Keep drop interactions to simple state + extras mutations, and use `.with_message` for feedback.
383
+ :::
384
+
385
+ There is no card "snap-back" to worry about on cancel — native drag never moves the card's DOM node, so canceling the modal just closes it and the card stays where it was.
386
+
387
+ ---
388
+
285
389
  ## Positioning
286
390
 
287
391
  By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
@@ -337,11 +441,17 @@ Each column loads at most 25 cards. When the total exceeds the limit, a `+N more
337
441
 
338
442
  ## Quick-add
339
443
 
340
- When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal, pre-filled with the values that `on_drop` would set.
444
+ When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal.
341
445
 
342
- Authorization: the button is only rendered when `create?` returns `true` in the current policy.
446
+ The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record — so the new card lands in the clicked column, appended to the bottom. `on_enter` runs against a real, persisted record (exactly as it does for a drag), so `update!`-style callbacks and any side effects behave identically and fire once, on the actual create.
343
447
 
344
- The pre-seeding works by doing a dry-run of `on_drop` against a sentinel record — it intercepts `save!`/`update!` to capture the attribute changes without writing to the database. Exotic `on_drop` callbacks with external side effects (API calls, background jobs) will fire on every `+ Add` click; keep `on_drop` to attribute assignment for clean quick-add behavior.
448
+ ::: warning Give your grouping column a default
449
+ Because `on_enter` runs **after** the record is saved, the record must be creatable **without** a grouping value. Give your grouping column (e.g. `status`) a database or model default. If it is `NOT NULL` with no default, quick-add create fails validation before `on_enter` can set it.
450
+ :::
451
+
452
+ If `on_enter` (or positioning) raises after the record was created, the create is **not** rolled back: the record is kept in its default column (validly positioned there) and the failure is surfaced as a toast.
453
+
454
+ Authorization: the button is only rendered when `create?` returns `true` in the current policy.
345
455
 
346
456
  ---
347
457
 
@@ -27,6 +27,25 @@ class TaskPolicy < ResourcePolicy
27
27
  end
28
28
  ```
29
29
 
30
+ ## Gating a specific transition (`from` / `to` context)
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:
33
+
34
+ ```ruby
35
+ class DealPolicy < ResourcePolicy
36
+ # Anyone on the team may shuffle cards, but only a manager may move one
37
+ # INTO "Closed Won".
38
+ def kanban_move?
39
+ return user.manager? if kanban_to&.key == :closed_won
40
+ super
41
+ end
42
+ end
43
+ ```
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.
46
+
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
+
30
49
  ## Read-only board
31
50
 
32
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.
@@ -36,14 +55,15 @@ When `kanban_move?` returns `false` for the current user, the board is rendered
36
55
  When a card is dropped, the server:
37
56
 
38
57
  1. Finds the record within the current authorized scope (the same policy `relation_scope` used by the index action).
39
- 2. Calls `authorize_current!(record, to: :kanban_move?)`. A `false` result halts the action with HTTP 403.
40
- 3. 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).
41
- 4. Enforces the destination column's `wip:` limit (cross-column moves only). Exceeding the WIP cap also responds 422.
42
- 5. Calls `on_drop` and repositions the record inside a transaction.
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).
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.
43
63
 
44
64
  ## No permitted attributes for moves
45
65
 
46
- Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `on_drop` callback is author code that runs with full model access — it is the responsibility of the `on_drop` 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.
47
67
 
48
68
  ## Column-level drop policies
49
69