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
  # Customizing the UI
2
2
 
3
- Plutonium's UI is built on Phlex, Tailwind 4, and Stimulus. Almost everything you see — pages, forms, displays, tables, components, layouts, even the design tokens — is open for override. This guide is the map. Each section shows the smallest useful example for one kind of customization, then points to the reference for the full surface.
3
+ Plutonium's UI is built on Phlex, Tailwind 4, and Stimulus. Almost everything you see (pages, forms, displays, tables, components, layouts, even the design tokens) is open for override. This guide is the map. Each section shows the smallest useful example for one kind of customization, then points to the reference for the full surface.
4
4
 
5
5
  When you're not sure where to start, read this top to bottom. When you know what you need, jump to the right section and follow the link to reference.
6
6
 
@@ -17,7 +17,7 @@ class PostDefinition < ResourceDefinition
17
17
  end
18
18
  ```
19
19
 
20
- Each nested class inherits from Plutonium's defaults and lets you override only the methods you care about. Don't reimplement the whole layer — use the render hooks below.
20
+ Each nested class inherits from Plutonium's defaults and lets you override only the methods you care about. Don't reimplement the whole layer; use the render hooks below.
21
21
 
22
22
  → See [Reference › UI › Pages](/reference/ui/pages) for the full hook list.
23
23
 
@@ -29,15 +29,17 @@ Most page customization is "I want to add something before/after this section."
29
29
  class PostDefinition < ResourceDefinition
30
30
  class ShowPage < ShowPage
31
31
  def render_before_content
32
- div(class: "pu-card pu-card-body") do
33
- plain "This post has #{object.comments.count} comments"
32
+ div(class: "pu-alert pu-alert-info", role: "status") do
33
+ div(class: "pu-alert-message") { t("blog.posts.show.comment_count", count: object.comments.count) }
34
34
  end
35
35
  end
36
36
  end
37
37
  end
38
38
  ```
39
39
 
40
- Hooks exist around the header, breadcrumbs, page header, toolbar, content, and footer — pick the one closest to where you want the thing to appear.
40
+ `pu-alert pu-alert-<success|warning|danger|info>` is the same banner the flash messages use, so it already has its dark-mode colors. The text comes from a locale key; see [i18n](/reference/i18n#your-own-components-and-pages).
41
+
42
+ Hooks exist around the header, breadcrumbs, page header, toolbar, content, and footer: pick the one closest to where you want the thing to appear.
41
43
 
42
44
  → See [Reference › UI › Pages](/reference/ui/pages) › Page hooks.
43
45
 
@@ -56,7 +58,7 @@ class Form < Form
56
58
  render_resource_field :published_at
57
59
  render_resource_field :category
58
60
  end
59
- render_actions # REQUIRED — without this, no submit button
61
+ render_actions # REQUIRED: without this, no submit button
60
62
  end
61
63
 
62
64
  private
@@ -126,14 +128,14 @@ class PostCardComponent < Plutonium::UI::Component::Base
126
128
  div(class: "pu-card pu-card-body") do
127
129
  h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
128
130
  p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
129
- a(href: resource_url_for(@post), class: "pu-btn pu-btn-sm pu-btn-ghost") { "Read more" }
131
+ a(href: resource_url_for(@post), class: "pu-btn pu-btn-sm pu-btn-ghost") { t("blog.posts.card.read_more") }
130
132
  end
131
133
  end
132
134
  end
133
135
  ```
134
136
 
135
137
  Use it directly in a page, or wire it as a field in the definition. A component
136
- with its own constructor takes the block form — you build it, so you decide what
138
+ with its own constructor takes the block form: you build it, so you decide what
137
139
  it receives:
138
140
 
139
141
  ```ruby
@@ -143,13 +145,13 @@ end
143
145
  ```
144
146
 
145
147
  `as: SomeComponent` is for a *field* component, which Plutonium constructs with
146
- the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`) — see the reference below.
148
+ the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`); see the reference below.
147
149
 
148
150
  → See [Reference › UI › Components](/reference/ui/components).
149
151
 
150
152
  ## Phlexi themes (recolor without rewriting)
151
153
 
152
- If all you want is to recolor or restyle the form/display/table, write a `Theme` class instead of overriding the template. Always `super.merge(...)` — never replace wholesale:
154
+ If all you want is to recolor or restyle the form/display/table, write a `Theme` class instead of overriding the template. Always `super.merge(...)`; never replace wholesale:
153
155
 
154
156
  ```ruby
155
157
  class Form < Form
@@ -173,7 +175,7 @@ By default `:new`, `:edit`, and every interactive action render in a slideover p
173
175
 
174
176
  ```ruby
175
177
  class PostDefinition < ResourceDefinition
176
- modal :slideover # default — slide-in from the right
178
+ modal :slideover # default: slide-in from the right
177
179
  # modal :centered # centered dialog
178
180
  # modal :centered, size: :lg # centered, wider container
179
181
  # modal false # full standalone page
@@ -184,7 +186,7 @@ end
184
186
 
185
187
  ## Layouts and the shell
186
188
 
187
- The layout is the chrome around every resource page — topbar, sidebar, flash region, scripts. Two ways to customize it:
189
+ The layout is the chrome around every resource page: topbar, sidebar, flash region, scripts. Two ways to customize it:
188
190
 
189
191
  ```bash
190
192
  # Per-portal: eject the shell partials and edit them directly
@@ -199,24 +201,24 @@ For programmatic overrides, subclass `Plutonium::UI::Layout::ResourceLayout` and
199
201
 
200
202
  ## Tailwind, Stimulus, and assets
201
203
 
202
- Plutonium ships with a Tailwind config, design tokens, and a set of Stimulus controllers. To plug into them in your app:
204
+ Plutonium ships with a Tailwind config, design tokens, and a set of Stimulus controllers. Out of the box the app serves the gem's prebuilt `plutonium.css` and `plutonium.min.js`, so app-side Tailwind classes, a new brand palette, token overrides and your own Stimulus controllers need your own bundles first. Run the generator rather than wiring the pipeline by hand:
203
205
 
204
206
  ```bash
205
207
  rails generate pu:core:assets
206
208
  ```
207
209
 
208
- This installs the npm packages, creates a `tailwind.config.js` that extends Plutonium's defaults via `plutoniumTailwindConfig.merge`, imports Plutonium's CSS, and registers its Stimulus controllers. After that, you can:
210
+ This installs the npm packages, creates a `tailwind.config.js` that extends Plutonium's defaults via `plutoniumTailwindConfig.merge`, imports Plutonium's CSS, registers its Stimulus controllers, and points `config.assets.stylesheet` / `config.assets.script` at your `application` bundles. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` to exist (an app created with `-j esbuild -c tailwind` plus Stimulus). After that, you can:
209
211
 
210
- - Extend the palette under `theme.extend.colors` (always inside `plutoniumTailwindConfig.merge` — a plain spread drops Plutonium's defaults).
211
- - Use `.pu-btn`, `.pu-card`, `.pu-input`, `.pu-table`, etc. instead of hand-rolling Tailwind chains.
212
+ - Extend the palette under `theme.extend.colors` (always inside `plutoniumTailwindConfig.merge`: a plain spread drops Plutonium's defaults), then rebuild the CSS.
213
+ - Use `.pu-btn`, `.pu-card`, `.pu-input`, `.pu-table`, `.pu-alert`, `.pu-badge`, etc. instead of hand-rolling Tailwind chains.
212
214
  - Reference design tokens directly: `bg-[var(--pu-surface)]`, `text-[var(--pu-text-muted)]`, `border-[var(--pu-border)]`. These auto-switch with dark mode.
213
- - Register your own Stimulus controllers alongside Plutonium's — `registerControllers(application)` is mandatory or the entire interactive layer is dead.
215
+ - Register your own Stimulus controllers alongside Plutonium's. Your `application.js` now replaces the gem's bundle, so `registerControllers(application)` (added by the generator) must stay, or the entire interactive layer is dead.
214
216
 
215
217
  → See [Reference › UI › Assets](/reference/ui/assets) for the full toolchain, the `.pu-*` class catalog, and design-token reference.
216
218
 
217
219
  ## ERB views (escape hatch)
218
220
 
219
- When the Phlex page class is the wrong tool — you want to keep an existing ERB layout, you're integrating with a designer's HTML, or you just want to surround the generated page with custom markup — drop an ERB view at the controller path:
221
+ When the Phlex page class is the wrong tool (you want to keep an existing ERB layout, you're integrating with a designer's HTML, or you just want to surround the generated page with custom markup), drop an ERB view at the controller path:
220
222
 
221
223
  ```
222
224
  app/views/posts/show.html.erb
@@ -237,7 +239,7 @@ Keep that line and wrap it to add chrome without giving up the generated page:
237
239
  <%= render partial: "related" %>
238
240
  ```
239
241
 
240
- Or replace the line entirely for full control. ERB views always win over the Phlex page class when both exist for the same action — reach for this only when Phlex hooks + overrides genuinely can't do the job.
242
+ Or replace the line entirely for full control. ERB views always win over the Phlex page class when both exist for the same action, so reach for this only when Phlex hooks + overrides genuinely can't do the job.
241
243
 
242
244
  ## When to reach for what
243
245
 
@@ -249,18 +251,19 @@ Or replace the line entirely for full control. ERB views always win over the Phl
249
251
  | Reuse a UI block across pages | Custom Phlex component |
250
252
  | Recolor without changing structure | Phlexi `Theme` class |
251
253
  | Swap the topbar or sidebar | `pu:eject:shell` or custom layout class |
252
- | Change brand color or radius | Design tokens — see [Theming](/guides/theming) |
254
+ | Change brand color | Tailwind `primary` palette via `plutoniumTailwindConfig.merge`, then rebuild. See [Theming](/guides/theming) |
255
+ | Change surfaces, borders or radius | Design tokens (mirrored in `.dark`). See [Theming](/guides/theming) |
253
256
  | Add a custom JS interaction | Stimulus controller registered alongside Plutonium's |
254
257
 
255
258
  ## Gotchas
256
259
 
257
- - **Don't override `view_template` in pages** when a render hook fits — you lose breadcrumbs, header, and DynaFrame (turbo-frame) behavior.
258
- - **`render_actions` is mandatory** when you write a custom `form_template` — otherwise the form has no submit button.
259
- - **Always `registerControllers(application)`** in `app/javascript/controllers/index.js`. Without it, every Plutonium-shipped Stimulus controller is dead (color mode, slim-select, flatpickr, easymde, form pre-submit).
260
+ - **Don't override `view_template` in pages** when a render hook fits, or you lose breadcrumbs, header, and DynaFrame (turbo-frame) behavior.
261
+ - **`render_actions` is mandatory** when you write a custom `form_template`, otherwise the form has no submit button.
262
+ - **Once the app serves its own JS bundle, `registerControllers(application)`** must be in `app/javascript/controllers/index.js` (`pu:core:assets` adds it). Without it, every Plutonium-shipped Stimulus controller is dead (color mode, slim-select, flatpickr, easymde, form pre-submit).
260
263
  - **Use `plutoniumTailwindConfig.merge`** when extending the Tailwind theme. A plain object spread drops Plutonium's defaults.
261
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
264
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** The classes and tokens switch with dark mode; a hand-written `gray-X/dark:gray-Y` pair needs its own dark twin and won't follow a rebrand.
262
265
 
263
266
  ## Related
264
267
 
265
- - [Theming](/guides/theming) — design tokens, brand colors.
266
- - [Reference › UI](/reference/ui/) — the full surface area for every override above.
268
+ - [Theming](/guides/theming): design tokens, brand colors.
269
+ - [Reference › UI](/reference/ui/): the full surface area for every override above.
@@ -4,7 +4,7 @@
4
4
  Dashboards are experimental: the DSL and behavior may change in a future release.
5
5
  :::
6
6
 
7
- A dashboard is a page of cards: headline numbers, charts and free-form panels, declared in one Ruby class and mounted with a single routes line. Every card loads in its own lazy turbo frame, so the page paints immediately and each card's queries run in a separate request as it scrolls into view.
7
+ A dashboard is a page of cards: headline numbers, charts and free-form panels, declared in one Ruby class and mounted with a single routes line. By default every card loads in its own lazy turbo frame, so the page paints immediately and each card's queries run in a separate request as it scrolls into view.
8
8
 
9
9
  ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/guides/dashboard-overview.png)
10
10
 
data/docs/guides/index.md CHANGED
@@ -25,12 +25,12 @@ aside: false
25
25
  { name: 'Nested resources', link: '/plutonium-core/guides/nested-resources' },
26
26
  { name: 'Multi-tenancy', link: '/plutonium-core/guides/multi-tenancy' },
27
27
  { name: 'Search & filtering', link: '/plutonium-core/guides/search-filtering' },
28
- { name: 'Wizards', desc: 'Multi-step flows — onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
29
- { name: 'Kanban boards', desc: 'Drag-and-drop board view — columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
28
+ { name: 'Wizards', desc: 'Multi-step flows: onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
29
+ { name: 'Kanban boards', desc: 'Drag-and-drop board view: columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
30
30
  { name: 'Dashboards', desc: 'Metric, chart and free-form cards, each loaded in its own lazy turbo frame.', link: '/plutonium-core/guides/dashboards' },
31
31
  ]},
32
32
  { group: 'Customization', items: [
33
- { name: 'Customizing the UI', desc: 'A map of the override surface — pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
33
+ { name: 'Customizing the UI', desc: 'A map of the override surface: pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
34
34
  { name: 'Theming', desc: 'Design tokens and brand colors.', link: '/plutonium-core/guides/theming' },
35
35
  ]},
36
36
  { group: 'Quality', items: [
@@ -1,29 +1,29 @@
1
1
  # Kanban Boards
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
- Turn any resource index into a drag-and-drop kanban board — columns, WIP limits, quick-add, column actions, and opt-in realtime — all from a single `kanban do…end` block in your definition.
7
+ Turn any resource index into a drag-and-drop kanban board: columns, WIP limits, quick-add, column actions, and opt-in realtime, all from a single `kanban do…end` block in your definition.
8
8
 
9
- ![A kanban board grouped by status — cards with badges, a WIP badge on Pending, a quick-add button, and collapsible columns](/images/guides/kanban-board.png)
9
+ ![A kanban board grouped by status: cards with badges, a WIP badge on Pending, a quick-add button, and collapsible columns](/images/guides/kanban-board.png)
10
10
 
11
11
  ## What you get
12
12
 
13
13
  - Drag cards between columns; the server persists the column change and the position within the column.
14
- - Decimal fractional positioning — cards always land exactly where you drop them without renumbering.
14
+ - Decimal fractional positioning: cards always land exactly where you drop them without renumbering.
15
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.
19
19
 
20
- ## Worked example — Task board
20
+ ## Worked example: Task board
21
21
 
22
- A complete board for a `Task` model grouped by status — migration, model, definition, and policy.
22
+ A complete board for a `Task` model grouped by status, migration, model, definition, and policy.
23
23
 
24
24
  ### 1. Migration
25
25
 
26
- The model needs a `decimal` position column. Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
26
+ The model needs a `decimal` position column. Use the **`t.position`** helper, it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
27
27
 
28
28
  ```ruby
29
29
  class CreateTasks < ActiveRecord::Migration[8.1]
@@ -102,25 +102,25 @@ class TaskPolicy < ResourcePolicy
102
102
  end
103
103
  ```
104
104
 
105
- ### 5. Routes — no changes needed
105
+ ### 5. Routes: no changes needed
106
106
 
107
107
  The `kanban_move` member route is wired automatically when the controller includes `Plutonium::Resource::Controllers::KanbanActions` (included by default in all Plutonium resource controllers).
108
108
 
109
109
  Visit the resource index and use the view switcher to select the Kanban view.
110
110
 
111
- ![After dragging a card from Doing to Done — both column frames re-render and the WIP badge on Doing updates in place](/images/guides/kanban-after-move.png)
111
+ ![After dragging a card from Doing to Done: both column frames re-render and the WIP badge on Doing updates in place](/images/guides/kanban-after-move.png)
112
112
 
113
113
  ### When a drop is rejected
114
114
 
115
- If a move is refused server-side — the destination is at its `wip:` limit, its `accepts:` policy rejects the card, or the source column is `locked:` — the card snaps back to where it started **and** a dismissable toast explains why:
115
+ If a move is refused server-side: the destination is at its `wip:` limit, its `accepts:` policy rejects the card, or the source column is `locked:`, then the card snaps back to where it started **and** a dismissable toast explains why:
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 `kanban_move?` denial.
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
 
123
- Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in) — full-page by default, or a **centered modal** that keeps the board visible behind it:
123
+ Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in), full-page by default, or a **centered modal** that keeps the board visible behind it:
124
124
 
125
125
  ![A card's show page open in a centered modal over the board, with an expand icon to open the full page](/images/guides/kanban-show-centered-modal.png)
126
126
 
@@ -135,14 +135,14 @@ end
135
135
  ```
136
136
 
137
137
  - Set `show_in :modal` on the **definition** to open show in a modal from the table, grid, and board alike. Set it on the **kanban block** to change only the board. An unset board inherits the definition (which defaults to `:page`).
138
- - The show modal is always **centered** — distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
138
+ - The show modal is always **centered**: distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
139
139
  - From inside the modal, an expand icon opens the record's full page in a new tab. ⌘/Ctrl-click (or middle-click) on a card does the same directly.
140
140
 
141
141
  ---
142
142
 
143
- ## Worked example — Status enum board
143
+ ## Worked example: Status enum board
144
144
 
145
- A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout — no explicit `card_fields` needed.
145
+ A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout, so no explicit `card_fields` needed.
146
146
 
147
147
  ```ruby
148
148
  class KitchenSinkDefinition < ResourceDefinition
@@ -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_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.
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
 
@@ -195,7 +195,7 @@ Use `columns do…end` when the column list depends on request context (`current
195
195
  ```ruby
196
196
  kanban do
197
197
  columns do
198
- # `self` is the view_context — current_user, params, helpers all work.
198
+ # `self` is the view_context: current_user, params, helpers all work.
199
199
  current_user.projects.map do |project|
200
200
  Plutonium::Kanban::Column.new(
201
201
  :"project_#{project.id}",
@@ -228,7 +228,7 @@ class TaskDefinition < ResourceDefinition
228
228
  end
229
229
  ```
230
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:`.
231
+ Note that **`enter_interaction:` is not supported on dynamic boards**: its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
232
232
  :::
233
233
 
234
234
  ### Column options
@@ -236,11 +236,11 @@ Note that **`enter_interaction:` is not supported on dynamic boards** — its hi
236
236
  | Option | Type | Default | Description |
237
237
  |--------|------|---------|-------------|
238
238
  | `label:` | String | `key.to_s.titleize` | Column header text |
239
- | `color:` | Symbol or String | `nil` | Dot color in the column header — `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
239
+ | `color:` | Symbol or String | `nil` | Dot color in the column header, `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
240
240
  | `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
241
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) |
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. 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. |
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
244
  | `role:` | `:backlog`, `:done`, `:lost` | `nil` | Preset shorthand (see below) |
245
245
  | `collapsed:` | Boolean | `false` | Start collapsed |
246
246
  | `add:` | Boolean | `false` | Show `+ Add` quick-add button |
@@ -256,7 +256,7 @@ Note that **`enter_interaction:` is not supported on dynamic boards** — its hi
256
256
  | `:done` | `color: :green, collapsed: true` |
257
257
  | `:lost` | `color: :red, collapsed: true` |
258
258
 
259
- `:done` and `:lost` are the two terminal roles (both collapsed by default) — the
259
+ `:done` and `:lost` are the two terminal roles (both collapsed by default), the
260
260
  won/lost pair for pipelines like leads, deals, or tickets; the colour signals the
261
261
  outcome.
262
262
 
@@ -284,8 +284,8 @@ column :done,
284
284
  end
285
285
  ```
286
286
 
287
- - `on: :all` — passes IDs of **all** cards in the column (ignoring `per_column`).
288
- - `on: :visible` — passes IDs of only the rendered, `per_column`-capped cards.
287
+ - `on: :all`: passes IDs of **all** cards in the column (ignoring `per_column`).
288
+ - `on: :visible`: passes IDs of only the rendered, `per_column`-capped cards.
289
289
 
290
290
  Column actions are rendered as buttons in the column header. They open the normal interactive-action modal (with form, authorization, success/failure handling) pre-loaded with the column's card IDs.
291
291
 
@@ -293,7 +293,7 @@ Column actions are rendered as buttons in the column header. They open the norma
293
293
 
294
294
  ## Interaction on drop
295
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.
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
297
 
298
298
  ```ruby
299
299
  column :lost,
@@ -301,13 +301,13 @@ column :lost,
301
301
  enter_interaction: MarkLostInteraction
302
302
  ```
303
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).
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
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.
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
307
 
308
308
  ### The interaction
309
309
 
310
- A drop interaction is an ordinary record-scoped interaction — nothing kanban-specific in the class:
310
+ A drop interaction is an ordinary record-scoped interaction with nothing kanban-specific in the class:
311
311
 
312
312
  ```ruby
313
313
  class MarkLostInteraction < ResourceInteraction
@@ -330,7 +330,7 @@ end
330
330
 
331
331
  ### Authorization
332
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`):
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
334
 
335
335
  ```ruby
336
336
  class TaskPolicy < ResourcePolicy
@@ -341,7 +341,7 @@ class TaskPolicy < ResourcePolicy
341
341
  end
342
342
  ```
343
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.
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
345
 
346
346
  ### Two flows, split by intent
347
347
 
@@ -353,9 +353,9 @@ This keeps authorization in one place: `kanban_move?` gates every move, and the
353
353
  A column can declare `on_enter:` and `enter_interaction:` together. When it does:
354
354
 
355
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.
356
+ - `enter_interaction` owns the **extras**: the reason, the mail, the audit trail.
357
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:
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
359
 
360
360
  ```ruby
361
361
  column :blocked,
@@ -364,25 +364,25 @@ column :blocked,
364
364
  enter_interaction: BlockTaskInteraction
365
365
  ```
366
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.
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
368
 
369
369
  ### Same-column drops run positioning only
370
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.
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
372
 
373
373
  ### Atomicity and failure
374
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.
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
376
 
377
377
  ### Success feedback and the response limitation
378
378
 
379
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
380
 
381
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.
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
383
  :::
384
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.
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
386
 
387
387
  ---
388
388
 
@@ -390,35 +390,35 @@ There is no card "snap-back" to worry about on cancel — native drag never move
390
390
 
391
391
  By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
392
392
 
393
- 1. A `decimal` database column — use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
393
+ 1. A `decimal` database column: use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
394
394
  2. `include Plutonium::Positioning::Model` in the model.
395
- 3. `positioned_on :position, scope: :status` — the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
395
+ 3. `positioned_on :position, scope: :status`: the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
396
396
 
397
397
  ### Position modes
398
398
 
399
- `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/resource/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter — the board resolves this lazily.
399
+ `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/resource/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter; the board resolves this lazily.
400
400
 
401
401
  ```ruby
402
402
  kanban do
403
- # Mode A (default) — delegate to Plutonium::Positioning::Model.
403
+ # Mode A (default): delegate to Plutonium::Positioning::Model.
404
404
  # Uses :position attribute, requires the model concern.
405
405
  position_on :position
406
406
 
407
407
  # Mode A with a custom attribute name:
408
408
  position_on :sort_order
409
409
 
410
- # Mode B — BYO positioning. The block receives a Move struct.
410
+ # Mode B: BYO positioning. The block receives a Move struct.
411
411
  # Use when you want to call a custom service or use a different ordering scheme.
412
412
  position_on :sort_order do |move|
413
- # move.record — the dropped record
414
- # move.column — the destination column key (Symbol)
415
- # move.prev — the record immediately before the drop slot (or nil)
416
- # move.next — the record immediately after the drop slot (or nil)
417
- # move.index — 0-based insertion index within the destination column
413
+ # move.record : the dropped record
414
+ # move.column : the destination column key (Symbol)
415
+ # move.prev : the record immediately before the drop slot (or nil)
416
+ # move.next : the record immediately after the drop slot (or nil)
417
+ # move.index : 0-based insertion index within the destination column
418
418
  MyPositioningService.call(move.record, prev: move.prev, next: move.next)
419
419
  end
420
420
 
421
- # Mode C — no ordering. Cards render in the relation's default order.
421
+ # Mode C: no ordering. Cards render in the relation's default order.
422
422
  # On-drop still fires; position is just never updated.
423
423
  position_on false
424
424
  end
@@ -445,7 +445,7 @@ Each column loads at most 25 cards. When the total exceeds the limit, a `+N more
445
445
 
446
446
  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.
447
447
 
448
- 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.
448
+ 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.
449
449
 
450
450
  ::: warning Give your grouping column a default
451
451
  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.
@@ -495,10 +495,10 @@ After a successful move, Plutonium broadcasts the updated column frames to all c
495
495
 
496
496
  Plutonium emits the `<turbo-cable-stream-source>` subscription element and broadcasts on the server, but the **client must have an ActionCable consumer** to receive it. Plutonium's bundled JavaScript ships `@hotwired/turbo` only (no cable client), so you must wire the rest up yourself:
497
497
 
498
- 1. **Gems** — `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
499
- 2. **Cable adapter** (`config/cable.yml`) — `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
500
- 3. **Mount ActionCable** — `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
501
- 4. **Load the cable client in your app's JavaScript** — this is the step most people miss. Add **one** of:
498
+ 1. **Gems**: `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
499
+ 2. **Cable adapter** (`config/cable.yml`): `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
500
+ 3. **Mount ActionCable**: `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
501
+ 4. **Load the cable client in your app's JavaScript**: this is the step most people miss. Add **one** of:
502
502
  ```js
503
503
  // app pack, alongside your other imports
504
504
  import "@hotwired/turbo-rails" // registers <turbo-cable-stream-source> + a consumer
@@ -511,7 +511,7 @@ Plutonium emits the `<turbo-cable-stream-source>` subscription element and broad
511
511
  Without this, the server broadcasts but no browser is subscribed, so other viewers won't update until they reload.
512
512
 
513
513
  ::: tip Verify it
514
- With two browser tabs on the same board, move a card in one — the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
514
+ With two browser tabs on the same board, move a card in one; the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
515
515
  :::
516
516
 
517
517
  ---
@@ -539,7 +539,7 @@ class TaskDefinition < ResourceDefinition
539
539
  # ...
540
540
  end
541
541
 
542
- # Call AFTER the kanban block — :kanban isn't a valid default until
542
+ # Call AFTER the kanban block: :kanban isn't a valid default until
543
543
  # `kanban` has enabled the view. Reversing the order raises ArgumentError
544
544
  # at class load.
545
545
  default_index_view :kanban