plutonium 0.62.2 → 0.63.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 (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -220,9 +220,11 @@ end
220
220
 
221
221
  ## Custom layouts
222
222
 
223
- ### Sectioned — prefer the `form_layout` DSL
223
+ ### Sectioned — prefer the `form_layout` / `display_layout` DSL
224
224
 
225
- **For grouping fields into sections, don't hand-roll a `Form` subclass — declare `form_layout` in the definition.** It handles headings, descriptions, collapsible `<details>`, per-section `columns:`, `condition:`-based visibility, and **auto-drops sections that resolve to zero fields** (so `+ New` doesn't sprout empty headings). See [[plutonium-resource]] › Form Layout.
225
+ **For grouping fields into sections, don't hand-roll a `Form` or `Display` subclass — declare `form_layout` (forms) or `display_layout` (show page) in the definition.** They handle headings, descriptions, collapsible `<details>`, `condition:`-based visibility, and **auto-drop sections that resolve to zero fields** (so `+ New` doesn't sprout empty headings). `columns:` is form-only — `display_layout` raises on it. See [[plutonium-resource]] › Form Layout / Display Layout.
226
+
227
+ **Each section renders as its own card** (`Plutonium::UI::Block`), so a sectioned form or show page has **no single outer card** — the form drops its own `pu-card` and the sections supply it. Don't add a card wrapper of your own around them.
226
228
 
227
229
  ```ruby
228
230
  class PostDefinition < ResourceDefinition
@@ -387,13 +389,15 @@ class PostDefinition < ResourceDefinition
387
389
  p(class: "mt-2 opacity-90") { object.excerpt }
388
390
  end
389
391
 
390
- Block do
391
- fields_wrapper do
392
- render_resource_field :author
393
- render_resource_field :published_at
394
- end
392
+ # `fields_wrapper` is ALREADY a card (it renders a Block internally),
393
+ # so do not wrap it in another one — that stacks two cards and doubles
394
+ # the border and shadow.
395
+ fields_wrapper do
396
+ render_resource_field :author
397
+ render_resource_field :published_at
395
398
  end
396
399
 
400
+ # `Block` is the card primitive: use it for your own content.
397
401
  Block do
398
402
  div(class: "prose max-w-none") { raw object.content }
399
403
  end
@@ -409,9 +413,28 @@ end
409
413
  | `render_fields` | All permitted fields |
410
414
  | `render_resource_field(name)` | One field |
411
415
  | `render_associations` | Association tabs (driven by `permitted_associations` — see [[plutonium-behavior]]) |
416
+ | `render_before_fields` / `render_after_fields` | Hooks around the fields — **Details tab only** |
412
417
  | `object` | The record |
413
418
  | `resource_fields`, `resource_associations` | Permitted lists |
414
419
 
420
+ ### Details-tab-only content
421
+
422
+ To add a banner or extra section that shows on the **Details** tab and not the association tabs, override `render_before_fields` / `render_after_fields` on the **Display** — not the ShowPage. The page-level `render_before_content` / `render_after_content` hooks wrap the whole content block, and the tablist lives inside it, so anything added there shows on every tab.
423
+
424
+ ```ruby
425
+ class PostDefinition < ResourceDefinition
426
+ class Display < Display
427
+ private
428
+
429
+ def render_before_fields
430
+ div(class: "pu-card pu-card-body mb-4") { plain "Only on the Details tab" }
431
+ end
432
+ end
433
+ end
434
+ ```
435
+
436
+ Both hooks are no-ops by default. `render_fields` is the Details tab body when the record has associations and the entire display when it doesn't, so the hooks fire in the Details context either way.
437
+
415
438
  ## Custom Table
416
439
 
417
440
  ```ruby
@@ -447,6 +470,40 @@ end
447
470
 
448
471
  ---
449
472
 
473
+ ## Drag-to-Reorder Affordance (`position_on`)
474
+
475
+ When a definition declares `position_on` (see [[plutonium-resource]]) the index **table**, the **card grid**, and **nested association tables** render a drag grip. Configuration is entirely in the definition — there is no UI-layer switch.
476
+
477
+ **What renders where:**
478
+
479
+ | Surface | Draggable element | Grip placement | Axis |
480
+ |---|---|---|---|
481
+ | Index / nested table | the **grip only**, never the `<tr>` | inside the first cell's existing left padding (content does not shift) | vertical |
482
+ | Card grid | the **grip only** | floated over the card's top-left corner | horizontal, wrap-aware |
483
+ | Kanban board | the **whole card** | — | both (cross-column) |
484
+
485
+ 🚨 **Never make a `<tr>` draggable.** Two silent regressions: `draggable="true"` disables text selection inside the element in every major browser (you lose copy-a-cell-value), and it fights `row_click_controller` — a drag that starts and ends in place still fires a click and navigates the user away. Kanban keeps whole-card dragging because neither applies to a kanban card; a **grid** card gets a grip because it *does* carry a row-click show affordance.
486
+
487
+ **Enabled state.** The grip is live only while the collection is sorted **ascending, by the position attribute, and nothing else**. Otherwise "drop me between these two rows" describes nothing. Under a foreign sort the Stimulus controller isn't attached at all and the grip renders as a **link that applies the position sort** — the disabled state is the way out of the disabled state, which is why `position_on` registers `sort <attr>`. Per record, the grip also requires `reposition?`.
488
+
489
+ **DOM contract** (relevant if you eject a table/grid or write a custom collection component):
490
+
491
+ ```
492
+ wrapper data-controller="positioned"
493
+ data-positioned-url-template-value="/things/__ID__/reposition"
494
+ data-positioned-axis-value="horizontal" # grid only
495
+ row/card data-positioned-row-id="<id>" # single source of truth for the record id
496
+ grip data-positioned-grip # a real <button> — tabbable
497
+ ```
498
+
499
+ The URL template is built off `current_page_path` (not `request.path`) so a post-rebalance re-render doesn't wire subsequent drops to `/things/5/reposition`. The controller POSTs `{prev_id, next_id, to_index}` plus `window.location.search` — the query string is load-bearing, since the endpoint re-renders through the ordinary index pipeline.
500
+
501
+ **Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> — deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order. Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
502
+
503
+ Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/positioning.md`.
504
+
505
+ ---
506
+
450
507
  # Part 4 — Component Kit & Custom Components
451
508
 
452
509
  ## Built-in shorthand kit
@@ -509,17 +566,47 @@ class PostCardComponent < Plutonium::UI::Component::Base
509
566
  end
510
567
  ```
511
568
 
512
- Use in a definition:
569
+ Use in a definition. A component with its **own constructor** (like the one above)
570
+ must use the **block form** — you build it:
513
571
 
514
572
  ```ruby
515
- display :card, as: PostCardComponent # custom display component
516
- input :color, as: ColorPickerComponent # custom input component
573
+ display :card do |field|
574
+ PostCardComponent.new(post: field.object)
575
+ end
517
576
 
518
577
  display :metrics do |field|
519
578
  MetricsChartComponent.new(data: field.value)
520
579
  end
521
580
  ```
522
581
 
582
+ 🚨 `as: SomeComponent` takes a **field component**, NOT a keyword-argument
583
+ component: Plutonium constructs it as `SomeComponent.new(field, **attributes)`, so
584
+ `display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
585
+ arguments`. A field component subclasses the Phlexi base for its surface and reads
586
+ `field` (`field.value`, `field.object`, `field.dom`, `attributes`):
587
+
588
+ ```ruby
589
+ class ColorPickerComponent < Phlexi::Form::Components::Base
590
+ include Phlexi::Form::Components::Concerns::HandlesInput # name/id/value plumbing
591
+ include Plutonium::UI::Component::Behaviour # optional: kit + resource helpers
592
+
593
+ def view_template
594
+ input(**attributes, type: "color", value: field.value)
595
+ end
596
+ end
597
+
598
+ class ChartComponent < Phlexi::Display::Components::Base
599
+ def view_template
600
+ div(class: "h-40", data: {controller: "chart", chart_series_value: field.value.to_json})
601
+ end
602
+ end
603
+ ```
604
+
605
+ ```ruby
606
+ input :color, as: ColorPickerComponent # every surface: form + filter panel
607
+ display :chart, as: ChartComponent # every surface: show + index column
608
+ ```
609
+
523
610
  ## `DynaFrameContent` pattern
524
611
 
525
612
  Enables frame-aware rendering: regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
@@ -747,7 +834,7 @@ import CustomController from "./custom_controller"
747
834
  application.register("custom", CustomController)
748
835
  ```
749
836
 
750
- Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, plus various internal UI controllers.
837
+ Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, `kanban`, `positioned` (drag-to-reorder), `row-click`, plus various internal UI controllers.
751
838
 
752
839
  Custom controller — standard Stimulus:
753
840
 
@@ -815,6 +902,8 @@ Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards,
815
902
  }
816
903
  ```
817
904
 
905
+ 🚨 **Mirror every `:root` override in `.dark`.** The app stylesheet loads after Plutonium's and `:root`/`.dark` have equal specificity, so a `:root`-only override beats Plutonium's `.dark` value even in dark mode — your light color ships into dark mode, often unreadably (e.g. translucent navy `--pu-text-subtle` is invisible on a dark surface). Every color token customized in `:root` MUST be re-asserted with a dark value in `.dark`.
906
+
818
907
  ## `.pu-*` component classes
819
908
 
820
909
  Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs.**
@@ -928,7 +1017,11 @@ end
928
1017
 
929
1018
  ### Form theme keys
930
1019
 
931
- `base`, `fields_wrapper`, `actions_wrapper`, `wrapper`, `inner_wrapper`, `label`, `invalid_label`, `valid_label`, `neutral_label`, `input`, `invalid_input`, `valid_input`, `neutral_input`, `hint`, `error`, `button`, `checkbox`, `select`.
1020
+ `base`, `sectioned_base`, `fields_wrapper`, `sections_wrapper`, `actions_wrapper`, `wrapper`, `inner_wrapper`, `label`, `invalid_label`, `valid_label`, `neutral_label`, `input`, `invalid_input`, `valid_input`, `neutral_input`, `hint`, `error`, `button`, `checkbox`, `select`, plus the shared section keys below.
1021
+
1022
+ `sectioned_base` replaces `base` when the definition declares a `form_layout` — the sections are cards, so the form itself stops being one.
1023
+
1024
+ ⚠️ **Width is NOT a theme key.** It's configuration (`page_width` / `form_width` on the definition, `Plutonium.configuration.default_page_width` globally) and is appended by `Form::Resource`/`Page::Show`, so overriding `base` or `fields_wrapper` restyles a surface without silently pinning its width. See [[plutonium-resource]] › Page Width.
932
1025
 
933
1026
  ## Display theme
934
1027
 
@@ -937,7 +1030,7 @@ class Display < Display
937
1030
  class Theme < Plutonium::UI::Display::Theme
938
1031
  def self.theme
939
1032
  super.merge(
940
- fields_wrapper: "grid grid-cols-3 gap-8",
1033
+ fields_inner: "pu-card-body grid grid-cols-3 gap-8", # the GRID
941
1034
  label: "text-sm font-bold text-[var(--pu-text-muted)] mb-1",
942
1035
  string: "text-lg text-[var(--pu-text)]",
943
1036
  markdown: "prose dark:prose-invert max-w-none"
@@ -949,7 +1042,15 @@ end
949
1042
 
950
1043
  ### Display theme keys
951
1044
 
952
- `fields_wrapper`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`.
1045
+ `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`.
1046
+
1047
+ ⚠️ **`fields_wrapper` is the CARD, `fields_inner` is the grid.** `fields_wrapper` is merged into a `Plutonium::UI::Block` (which supplies `pu-card` itself), so putting grid classes there styles the card, not the fields. Override `fields_inner` to change the unsectioned grid, and `section_grid` to change the grid inside a `display_layout` section.
1048
+
1049
+ ### Section theme keys (forms *and* displays)
1050
+
1051
+ Section chrome is shared: `Plutonium::UI::Component::Section::DEFAULT_THEME` is merged into **both** `Form::Theme` and `Display::Theme`, so the two read identically by default while staying independently overridable.
1052
+
1053
+ `section_wrapper` (merged into the section's Block — Block already supplies `pu-card`), `section_header`, `section_summary` (the collapsible header row), `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`, plus `sections_wrapper` (the container that stacks sections).
953
1054
 
954
1055
  ## Table theme
955
1056
 
@@ -11,15 +11,16 @@ For the field/input vocabulary used inside a step, load [[plutonium-resource]].
11
11
 
12
12
  ## 🚨 Critical (read first)
13
13
 
14
- - **Enable the subsystem first.** `config.wizards.enabled = true` in `config/initializers/plutonium.rb`, then `rails db:migrate`. It's `false` by default — without it there's no `plutonium_wizard_sessions` table.
14
+ - **Enable the subsystem first.** `rails g pu:wizards:install` flips `config.wizards.enabled = true` and schedules `SweepJob`; then `rails db:migrate`. (By hand: the flag in `config/initializers/plutonium.rb`.) It's `false` by default — without it there's no `plutonium_wizard_sessions` table.
15
15
  - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception** — a non-bang `false` advances the wizard and silently loses data. Or call `fail!("msg")`.
16
16
  - **`data` is step-keyed:** `data.<step>.<field>` (e.g. `data.company.name`, `data.plan.plan`). Each step has its own typed sub-object, so two steps may share a field name without colliding. Read a field through its owning step everywhere (`condition:`/`on_submit`/`execute`).
17
17
  - **`condition:` lambdas must be nil-safe.** They run against `data` at every transition, including before their deciding step is filled (value is `nil`). `-> { data.plan.plan == "pro" }` ✓; `-> { data.plan.plan.upcase == "PRO" }` raises on nil ✗.
18
18
  - **`review` must be the LAST step.** A step declared after `review` raises at load.
19
19
  - **`using:` targets a MODEL only** — not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
20
20
  - **No generator.** Author wizards by hand, like interactions. They live in `app/wizards/`.
21
+ - **A wizard is a presentation object** — it's built with `view_context:`, so anything reachable only through `execute`/`on_submit` is reachable only from a wizard run. Logic may *start* there; the **second caller** (a job, an API, a rake task, the console) is the trigger to move it onto the **model**. See [[plutonium-behavior]] › Part 3 › Where the logic goes.
21
22
  - **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone — for an **authenticated** main-app wizard you MUST define your own `::WizardsController` (include `Plutonium::Wizard::Controller` + your auth concern); the synthesized fallback is **bare (no auth)**. Resource-anchored (`wizard` macro) wizards always run embedded on the resource controller.
22
- - **Schedule `SweepJob`** (a periodic job/cron). It reaps abandoned/expired sessions — always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
23
+ - **Schedule `SweepJob`** — `pu:wizards:install` does it for you when Solid Queue is in the bundle; otherwise add a periodic job/cron yourself. It reaps abandoned/expired sessions — always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
23
24
 
24
25
  ---
25
26
 
@@ -53,7 +54,7 @@ The ASK gate resolves the *design*; this confirms the app can actually *run* it.
53
54
  | Anchor model exists & reachable | Read the model an `anchored` wizard runs against | Missing/unreadable anchor ⇒ 404 / `NotAnchoredError` |
54
55
  | Host portal exists & its scoping | Read the portal engine (`scope_to_entity`?) + its real module name | Tenant folds into run identity; a guessed portal name breaks `register_wizard` |
55
56
  | Guest-flow prereqs | AR encryption keys if `encrypt_data`; no `concurrency_key`/`one_time` with `anonymous` | First write raises otherwise |
56
- | `on_submit` ⇒ SweepJob scheduled | The recurring-job config | Abandoned mid-flow records pile up forever |
57
+ | `on_submit` ⇒ SweepJob scheduled | grep `config/recurring.yml` for `sweep_abandoned_wizards` | Abandoned mid-flow records pile up forever |
57
58
 
58
59
  **Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional — say so; don't present a guessed field/column mapping as final.
59
60
 
@@ -97,6 +98,8 @@ end
97
98
  - `review` — built-in terminal step: auto-summary + gated Finish. Must be last.
98
99
  - `execute` — runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
99
100
 
101
+ ⚠️ **`execute` is a presentation boundary, same as an interaction's.** A wizard is built with `view_context:` too, so anything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow* — which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it (the API signup that skips onboarding, an admin backfill, an importer): `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. Same for `on_submit`/`on_rollback` — they're flow hooks, not a home for domain logic. Full rule: [[plutonium-behavior]] › Part 3 › Where the logic goes.
102
+
100
103
  ## Wizard-level macros
101
104
 
102
105
  | Macro | Meaning |
@@ -111,6 +114,7 @@ end
111
114
  | `one_time` | Retain the completed row at the `concurrency_key` → run once (gate-able). **Requires `concurrency_key`.** Omit → row deleted on complete (repeatable). |
112
115
  | `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). |
113
116
  | `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys — first write raises (naming the wizard) if unconfigured. Unset inherits `config.wizards.encrypt_data` (global default, off); `encrypt_data false` opts out when that default is on. |
117
+ | `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full` (`:full` = unconstrained). Unset inherits `config.wizards.width` (default `:md`). Independent of `config.default_page_width` — resource page width does not move wizards. |
114
118
  | `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`** — a guest's identity is its session token (already session-keyed/repeatable); whichever macro is declared last raises. |
115
119
 
116
120
  ## Branching — `condition:`
@@ -127,6 +131,26 @@ end
127
131
 
128
132
  `condition:` can also read `anchor`. Branch-hidden steps' data is pruned before `execute`. **Must be nil-safe** (see Critical).
129
133
 
134
+ Conditions are re-evaluated against the submission that was just staged, so a step (including `review`) may be gated on an answer from the step immediately before it — the revealed steps become reachable on that same POST. When a step's answer reveals nothing after it, that POST ends the flow and runs `execute`.
135
+
136
+ ### Revealing a field within a step — `pre_submit:`
137
+
138
+ A step-level `condition:` branches whole steps against *stored* data. To show or hide a field as the user edits a **sibling field on the same step**, mark the deciding input `pre_submit: true` — changing it re-renders the step form from the **just-submitted** values (same mechanism as resource forms and interactive actions):
139
+
140
+ ```ruby
141
+ step :details do
142
+ attribute :contact_pref, :string
143
+ input :contact_pref, as: :select, pre_submit: true,
144
+ choices: {none: "Don't contact me", email: "Email me"}
145
+
146
+ attribute :contact_email, :string
147
+ # `object` here is the step's data, seeded from what was just typed.
148
+ input :contact_email, condition: -> { object.contact_pref == "email" }
149
+ end
150
+ ```
151
+
152
+ A `pre_submit` is **render-only**: it never persists, never marks the step submitted, and never moves the cursor — abandon the page and nothing durable is left. **Attachment fields are exempt** from the re-render's seeding: a file input doesn't re-post on a sibling's `change`, so an already-staged upload survives untouched instead of being blanked out of the form. Uploads stage only on a real submit.
153
+
130
154
  ## Field reuse — `using:` a model
131
155
 
132
156
  `using:` is a **step option** (not a block method) and targets a **model only**.
@@ -179,6 +203,31 @@ Repeater rows rehydrate from staged `data` on GET (resume / back re-renders fill
179
203
 
180
204
  Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception — they carry no validators, so no markers there.)
181
205
 
206
+ ### Options that depend on the run
207
+
208
+ The step block runs **once, when the class loads**, so a literal option is frozen for every run. A **proc-valued** field/input option is resolved on **every render**. It must **take the form**: `form.wizard` is the run (so `anchor`, `data`, `persisted`, `current_user` are all reachable) and `form.object` is that step's staged data.
209
+
210
+ ```ruby
211
+ step :plan do
212
+ attribute :tier
213
+ input :tier, as: :select, choices: -> { anchor.available_tiers }
214
+ end
215
+ ```
216
+
217
+ It is also how a custom component receives per-run configuration:
218
+
219
+ ```ruby
220
+ input :answers, as: MyManifestComponent, config: -> { anchor.manifest }
221
+ ```
222
+
223
+ A one-argument proc still takes the form, as on any other form, for `object` (this step's staged data), `params` and helpers: `choices: ->(form) { form.object.region_tiers }`.
224
+
225
+ Three limits worth knowing:
226
+
227
+ - The **field set** is still fixed at class load — a proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup pass a **block** to `input` instead; it renders in the form's context with the field yielded.
228
+ - A **step's** `condition:` runs against the wizard; a **field's** `condition:` runs against the form (`object` = that step's staged data). Only the field-level one is excluded from the resolution above.
229
+ - **Proc options resolve on every form** ([[plutonium-resource]]), by one rule with no wizard exception: a zero-argument proc keeps its own binding, a one-argument one gets the form. A step block is `instance_exec`'d against an internal field recorder, so `-> { anchor.x }` raises `NameError` — take the form and use `form.wizard`. Same trade a `form_layout` section option makes; an `input` line keeps its meaning when moved between a definition and a step.
230
+
182
231
  ## Attachment fields (file uploads)
183
232
 
184
233
  A step can collect a file. Declare it like any field — a **`:string`** attribute (it holds the upload **token**, not the bytes) + a file input:
@@ -241,6 +290,8 @@ Always lists invalid/unvisited steps as fix-this jump links; Finish disabled unt
241
290
 
242
291
  The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String — Phlex renders the returned String too, double-rendering it.
243
292
 
293
+ **The summary resolves choice labels.** A field declared with the `choices:` option summarises as the label its `<option>` carried, not the stored value — `42` reads as "Alice", `"cash"` as "Cash". Every collection shape the input accepts works (pair arrays, `{value => label}` hashes, ranges, sets, AR relations, procs returning any of those), because resolution goes through the same `Phlexi::Form::SimpleChoicesMapper` the input uses. **Caveat:** choices supplied inside a *block* (`input(:x) { |f| f.select_tag choices: … }`) are computed at render time and aren't visible to the summary — those fields still show the raw value. Use the declarative `choices:` option when you want the review page to read well.
294
+
244
295
  ## Per-step writes — `on_submit` / `persist` / `on_rollback`
245
296
 
246
297
  `execute` is the default (atomic). Use `on_submit` **only** when a real record must exist mid-flow (external handoff, reviewer sees partials, payload too large for the row).
@@ -268,6 +319,8 @@ end
268
319
 
269
320
  `on_submit` is not atomic across steps (HTTP), which is why `cleanup_after` + `SweepJob` exist.
270
321
 
322
+ **Keep the hook to flow, not domain.** `on_submit`/`on_rollback` belong to one wizard step and can't be called from anywhere else, so they should say *when* and *what gets tracked* — one call to a model, as above. Once "authorize a card and record the billing row" is something the API does too, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
323
+
271
324
  ## Accessors
272
325
 
273
326
  | Accessor | Returns |
@@ -278,6 +331,8 @@ end
278
331
  | `succeed(v)` / `failed(errs)` | Outcome helpers (alias `success`). `.with_message`, `.with_redirect_response` chainable. |
279
332
  | `fail!(msg)` / `fail!(:field, msg)` | Raise a `StepError` from `on_submit`/`execute`. |
280
333
 
334
+ Available inside steps, `condition:`, `on_submit`, `on_rollback` and `execute` — and, via `form.wizard`, inside a step's **proc-valued field/input options** (see **Step internals → Options that depend on the run**).
335
+
281
336
  ## Anchoring
282
337
 
283
338
  ```ruby
@@ -419,7 +474,20 @@ The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<
419
474
 
420
475
  `Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it — returning that user's in-progress runs **for the current portal**, newest-first, for a "continue where you left off" dashboard. A run is only ever listed (and linked) by the portal it was launched in: a non-scoped portal lists only unscoped runs, a scoped portal narrows to the current tenant. (Two portals can share an entity scope, so the launching portal — the `engine` — is recorded per-run; scope alone can't identify it.)
421
476
 
422
- Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes — `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, the named route for a `register_wizard` mount; `nil` + `resume_unresolved_reason` when the row can't be resolved here (e.g. a non-anchored `wizard`-macro run). **Narrowing.** For the per-record / per-wizard resume widget ("does this record have an unfinished draft of wizard X?"), pass the optional `anchor:`/`wizard:` filters — they narrow **in the query, before enrichment**, so discarded rows are never URL-resolved or anchor-loaded (cheaper than `select`-ing the array, which enriches every row first). They compose, and the `wizard + anchor` pair is index-covered: `…in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first`. Don't reach into `e.session.anchor` to filter (a polymorphic load per row). For ad-hoc post-filtering the array still works — `e.wizard_class` is already on each entry.
477
+ Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes — the named route for a `register_wizard` mount, `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, and for a **non-anchored** `wizard`-macro run the resource whose definition registers that wizard class; `nil` + `resume_unresolved_reason` when the row can't be resolved here.
478
+
479
+ Each entry also exposes a **`cancel_url`** — the `DELETE` target that abandons the run — resolved from the *same* mount through its own named cancel route. Never derive it by string-munging `resume_url`: that drops query params and mis-resolves for a run with no `current_step` (whose resume URL is the bare launch path). The two resolve **independently**, so an unresumable row is still cancellable rather than stranded in the list. Render it as a **form**, not a link — cancelling runs every step's `on_rollback`, destroys its `persist`'d records, and deletes the row:
480
+
481
+ ```ruby
482
+ form(action: entry.cancel_url, method: "post") do
483
+ input(type: "hidden", name: "_method", value: "delete")
484
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
485
+ # `turbo_confirm`, NOT `confirm` — `data-confirm` is Rails UJS and never fires under Turbo.
486
+ button(type: "submit", data: {turbo_confirm: "Discard this draft? This can't be undone."}) { "Cancel" }
487
+ end
488
+ ```
489
+
490
+ **Narrowing.** For the per-record / per-wizard resume widget ("does this record have an unfinished draft of wizard X?"), pass the optional `anchor:`/`wizard:` filters — they narrow **in the query, before enrichment**, so discarded rows are never URL-resolved or anchor-loaded (cheaper than `select`-ing the array, which enriches every row first). They compose, and the `wizard + anchor` pair is index-covered: `…in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first`. Don't reach into `e.session.anchor` to filter (a polymorphic load per row). For ad-hoc post-filtering the array still works — `e.wizard_class` is already on each entry.
423
491
 
424
492
  ## Storage & config
425
493
 
@@ -431,6 +499,7 @@ Plutonium.configure do |config|
431
499
  config.wizards.encrypt_data = false # encrypt every wizard's data at rest (needs AR encryption keys)
432
500
  config.wizards.database = :primary # reserved — v1 supports :primary only (else raises at boot)
433
501
  config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect active_shrine/AS)
502
+ config.wizards.width = :md # default step page width (:sm/:md/:lg/:xl/:full) — NOT tied to default_page_width
434
503
  end
435
504
  ```
436
505
 
data/CHANGELOG.md CHANGED
@@ -2,6 +2,73 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.63.0] - 2026-08-22
6
+
7
+ ### Bug Fixes
8
+
9
+ - Normalize default_phone_country to lowercase ISO2
10
+ - Stop formatter: leaking into typed display components
11
+ - Stop copied input classes from restyling the slim select panel
12
+ - Explain the draft-only Publish menu with both action policies
13
+ - Show the PublishPost interaction and unclip long file captions
14
+ - Drop the redundant is_a? guard from the tour policy snippet
15
+ - Nest litestream and job roles under servers: in generated deploy.yml
16
+ - Let action dropdowns size to their longest label ([#70](https://github.com/radioactive-labs/plutonium-core/issues/70))
17
+ - [**breaking**] Isolate Rodauth session state per configuration ([#73](https://github.com/radioactive-labs/plutonium-core/issues/73))
18
+ - Stop a nil entity_scope persisting in the policy context ([#74](https://github.com/radioactive-labs/plutonium-core/issues/74))
19
+ - Stop a component-class `as:` crashing on `to_sym` ([#75](https://github.com/radioactive-labs/plutonium-core/issues/75))
20
+ - Honour `as: :hidden` when declared on input ([#83](https://github.com/radioactive-labs/plutonium-core/issues/83))
21
+ - Resolve the parent on routes nested under a singular resource ([#81](https://github.com/radioactive-labs/plutonium-core/issues/81))
22
+ - [**breaking**] Decide wizard finalize after staging the submission ([#89](https://github.com/radioactive-labs/plutonium-core/issues/89))
23
+ - Size layouts to the visible viewport, not 100vh ([#90](https://github.com/radioactive-labs/plutonium-core/issues/90))
24
+ - Call to_s on attachment filename before Phlex title attribute ([#92](https://github.com/radioactive-labs/plutonium-core/issues/92))
25
+ - Relation_scope written as an instance method silently does nothing ([#96](https://github.com/radioactive-labs/plutonium-core/issues/96))
26
+ - Stop Turbo's cross-document node swap failing system tests ([#98](https://github.com/radioactive-labs/plutonium-core/issues/98))
27
+ - Retry slim-select instead of waiting on it ([#105](https://github.com/radioactive-labs/plutonium-core/issues/105))
28
+ - Apply an association's scope when creating through a nested route ([#106](https://github.com/radioactive-labs/plutonium-core/issues/106))
29
+
30
+ ### Documentation
31
+
32
+ - Add homepage depth & proof upgrade design spec
33
+ - Warn that :root token overrides must be mirrored in .dark
34
+
35
+ ### Features
36
+
37
+ - Allow opting out of the grid card footer with `footer: false`
38
+ - Add render_before_fields/render_after_fields display hooks
39
+ - Hide show-page metadata rail in a kanban card's modal
40
+ - Add link:/button: HTML attribute bags to actions
41
+ - Homepage depth & proof upgrade ([#69](https://github.com/radioactive-labs/plutonium-core/issues/69))
42
+ - Collapse overflowing breadcrumbs, fix singular-route segments ([#72](https://github.com/radioactive-labs/plutonium-core/issues/72))
43
+ - Generate a Rodauth ManagementConstraint when an admin account exists
44
+ - Resolve proc-valued input options on every form ([#87](https://github.com/radioactive-labs/plutonium-core/issues/87))
45
+ - Eager-load what a collection is about to render ([#91](https://github.com/radioactive-labs/plutonium-core/issues/91))
46
+ - Native drag-and-drop reordering for tables, nested tables and grids ([#77](https://github.com/radioactive-labs/plutonium-core/issues/77))
47
+ - Add display_layout and render sections as cards ([#93](https://github.com/radioactive-labs/plutonium-core/issues/93))
48
+ - Constrain standalone form width ([#94](https://github.com/radioactive-labs/plutonium-core/issues/94))
49
+ - Per-wizard width override, independent of resource pages ([#97](https://github.com/radioactive-labs/plutonium-core/issues/97))
50
+ - Resume/cancel flow, pre_submit, and Turbo Stream completion ([#68](https://github.com/radioactive-labs/plutonium-core/issues/68))
51
+ - Run interactions out of band on a persisted, resumable run ([#99](https://github.com/radioactive-labs/plutonium-core/issues/99))
52
+ - Carry files and typed attributes into the run ([#100](https://github.com/radioactive-labs/plutonium-core/issues/100))
53
+ - Install generator, sweep concurrency, and an experimental template ([#101](https://github.com/radioactive-labs/plutonium-core/issues/101))
54
+ - Add a blog with RSS and the Plutonium launch post ([#102](https://github.com/radioactive-labs/plutonium-core/issues/102))
55
+ - Refresh the runs index while work is outstanding ([#104](https://github.com/radioactive-labs/plutonium-core/issues/104))
56
+ - Declare which associations get nested routes ([#107](https://github.com/radioactive-labs/plutonium-core/issues/107))
57
+
58
+ ### Miscellaneous Tasks
59
+
60
+ - Patch pagy CVE, five npm advisories, refresh appraisal locks ([#78](https://github.com/radioactive-labs/plutonium-core/issues/78))
61
+ - Prune the 0.49.0 notice from post_install_message ([#80](https://github.com/radioactive-labs/plutonium-core/issues/80))
62
+ - Clear 11 npm advisories via lockfile updates
63
+
64
+ ### Refactoring
65
+
66
+ - Drop is_a? guards from record-action policy methods
67
+
68
+ ### Testing
69
+
70
+ - Update the nested-association tests to the route-config lookup ([#88](https://github.com/radioactive-labs/plutonium-core/issues/88))
71
+
5
72
  ## [0.62.2] - 2026-07-09
6
73
 
7
74
  ### Bug Fixes
data/CLAUDE.md CHANGED
@@ -159,6 +159,93 @@ yarn docs:build # Build for production
159
159
  2. Add helper method in `Plutonium::Interaction::Outcome::Success`
160
160
  3. Document usage
161
161
 
162
+ ## Writing Docs and Blog Posts
163
+
164
+ Docs and posts in this repo are read by two audiences: developers, and AI assistants
165
+ (the skills in `.claude/skills/` are synced to `docs/public/skills/` and shipped).
166
+ A wrong example does not just mislead a reader. It gets reproduced as code.
167
+
168
+ ### Verify every code sample
169
+
170
+ **Never publish a snippet you have not checked against this codebase.** Reading the
171
+ docs is not enough; the docs have been wrong. Two real cases:
172
+
173
+ - `as: :phlexi_tag` was in three docs and a skill. `ResolvesTags#component_for`
174
+ dispatches via `send(:"#{tag}_tag")`, so it resolved to `phlexi_tag_tag` and
175
+ raised `NoMethodError`. The working values are `:phlexi_render` and `:phlexi`.
176
+ - A per-portal override example declared `as: :text` on a text column (dead code)
177
+ and commented that the definition decides which fields admins see (it does not).
178
+
179
+ When a claim is mechanical, prove it at runtime rather than by reading:
180
+
181
+ ```bash
182
+ cd test/dummy && RAILS_ENV=test bin/rails runner 'puts SomeClass.instance_methods.grep(/x/)'
183
+ ```
184
+
185
+ ### Invariants a sample must not violate
186
+
187
+ - **Don't declare what is auto-detected.** `field :title` matching the detected type
188
+ is dead code (`reference/resource/definition.md`).
189
+ - **Definition = how a field renders. Policy = whether it appears.** "Only admins see
190
+ this" is `permitted_attributes_for_*`, never a definition line or a `condition:`.
191
+ - **Pages expose the record as `object`**, not `resource`.
192
+ - **Interactions:** `attribute :resource` (no `class:`); rescue
193
+ `ActiveRecord::RecordInvalid` around any `create!`/`update!`/`save!`.
194
+ - **Policies:** never put `*_attributes` hashes in `permitted_attributes_for_*`.
195
+
196
+ ### Voice
197
+
198
+ Match the reference docs: direct, concrete, no throat-clearing. Specifically avoid
199
+ the tells that make writing read as machine-generated:
200
+
201
+ - **No em dashes.** Use commas, colons, parentheses, or two sentences.
202
+ - No "It's not X, it's Y" or "The pitch isn't… The pitch is…" constructions.
203
+ - No tidy-summary line after every example ("That's the Rails bargain in miniature").
204
+ - No stacked negation before the real claim, no "critically", no "genuinely".
205
+ - Cut a sentence that restates the one before it.
206
+
207
+ Deliberate parallelism and a stated opinion are fine. The tell is *unearned*
208
+ rhetoric, not rhetoric itself.
209
+
210
+ ### Blog mechanics
211
+
212
+ Posts are markdown in `docs/blog/`. Frontmatter:
213
+
214
+ ```yaml
215
+ ---
216
+ title: "Introducing Plutonium: Rails conventions, past CRUD" # must read cold
217
+ titleTemplate: "Plutonium Blog"
218
+ date: 2026-08-19
219
+ description: One sentence. Becomes the RSS description and the index subtitle.
220
+ author: Stefan Froelich
221
+ tags: [announcement, rails]
222
+ draft: true # omit to publish
223
+ ---
224
+ ```
225
+
226
+ - Put `<BlogMeta />` directly under the `# ` heading; it renders date, author and
227
+ the back link from frontmatter.
228
+ - **A title must be legible with no context.** It travels via RSS, social and pasted
229
+ links, where the site name is absent, so include both "Plutonium" and "Rails".
230
+ "Plutonium" alone collides with the element and other projects.
231
+ - **The slug must match the title.** Renaming the title means renaming the file;
232
+ URLs are permanent once published.
233
+ - A post is distinguished from the section index by having a `date`. `draft: true`
234
+ hides it from the index and the feed.
235
+ - `yarn docs:build` must pass. Dead internal links fail the build.
236
+
237
+ ### Screenshots
238
+
239
+ Match the existing ones or they look foreign next to each other:
240
+
241
+ - **2480px wide** (1240 CSS at `deviceScaleFactor: 2`); height varies with content.
242
+ - **Light mode, icon rail collapsed.** In `test/dummy`, set
243
+ `localStorage.theme = 'light'` and `localStorage.pu_rail_pinned = 'false'`.
244
+ - Capture from the running dummy app with realistic, **distinct** data. Generated
245
+ titles that repeat across rows read as broken data.
246
+ - Crop dead space: `magick shot.png -crop 2480x1290+0+0 +repage shot.png`.
247
+ - Live under `docs/public/images/<section>/`.
248
+
162
249
  ## Skills System
163
250
 
164
251
  The `.claude/skills/` directory contains documentation for AI assistants:
data/Rakefile CHANGED
@@ -35,12 +35,46 @@ end
35
35
  task :test_generators do
36
36
  failures = []
37
37
 
38
+ # This task DESTROYS uncommitted work under test/dummy — `git checkout --` plus
39
+ # `git clean -fd`, unconditionally, after every file. That is required (see
40
+ # below) but it is not something to discover afterwards, so refuse to start on
41
+ # a dirty tree. Set FORCE=1 to run anyway; CI checkouts are clean and never
42
+ # trip this.
43
+ dirty = `git status --porcelain -- test/dummy`.strip
44
+ if !dirty.empty? && ENV["FORCE"] != "1"
45
+ abort <<~MSG
46
+ test/dummy has uncommitted changes:
47
+
48
+ #{dirty.lines.map { |l| " #{l}" }.join}
49
+ This task git-restores and git-cleans test/dummy between generator test
50
+ files, which would discard them. Commit or stash first, or re-run with
51
+ FORCE=1 to accept the loss.
52
+ MSG
53
+ end
54
+
55
+ # Between files, not just inside them. A generator that shells out — e.g.
56
+ # `generate "pu:pkg:package"` — goes through Rails' `generate` action, which
57
+ # passes abort_on_failure: true, so Thor calls `abort` in the test process
58
+ # when the child fails. SystemExit skips minitest's teardown, so anything the
59
+ # test wrote into test/dummy survives; since every generator test file boots
60
+ # the dummy app at require time, one aborted file would take down every file
61
+ # after it. Cleaning here can't be skipped by an aborting child.
62
+ #
63
+ # An at_exit hook inside the tests cannot do this job: minitest runs the whole
64
+ # suite from its own at_exit, and handlers fire LIFO, so a hook registered
65
+ # later runs before any test does.
66
+ restore_dummy_app = lambda do
67
+ system("git", "checkout", "--", "test/dummy", out: File::NULL, err: File::NULL)
68
+ system("git", "clean", "-fd", "test/dummy", out: File::NULL, err: File::NULL)
69
+ end
70
+
38
71
  FileList["test/generators/**/*_test.rb"].sort.each do |test_file|
39
72
  puts "\n=== #{test_file} ==="
40
73
  # in: File::NULL — prevents a stray sub-generator prompt from hanging on the inherited TTY.
41
74
  unless system(Gem.ruby, "-w", "-Ilib:test", test_file, in: File::NULL)
42
75
  failures << test_file
43
76
  end
77
+ restore_dummy_app.call
44
78
  end
45
79
 
46
80
  if failures.any?
data/SECURITY.md CHANGED
@@ -12,7 +12,7 @@ please upgrade before reporting an issue to confirm it still reproduces.
12
12
 
13
13
  | Version | Supported |
14
14
  | ------- | ------------------ |
15
- | Latest release (`0.62.x`) | :white_check_mark: |
15
+ | Latest release (`0.63.x`) | :white_check_mark: |
16
16
  | Older releases | :x: |
17
17
 
18
18
  ## Reporting a Vulnerability