plutonium 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plutonium-resource
3
- description: Use BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, hidden actions, index views, drag-to-reorder (positioned_on / position_on), page customization. The single source for "what is a resource and how do I configure one".
3
+ description: 'Use BEFORE creating, scaffolding, or editing any Plutonium resource: model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, hidden actions, index views, drag-to-reorder (positioned_on / position_on), page customization. The single source for "what is a resource and how do I configure one".'
4
4
  ---
5
5
 
6
6
  # Plutonium Resources
@@ -14,29 +14,29 @@ For tenancy / `associated_with` / `relation_scope`, load [[plutonium-tenancy]].
14
14
  - **Always use generators.** `pu:res:scaffold` creates the resource; `pu:res:conn` connects it to a portal. Never hand-write the model, migration, policy, definition, or controller.
15
15
  - **Pass `--dest`** on every scaffold: `--dest=main_app` or `--dest=package_name`. Skips the interactive prompt.
16
16
  - **Quote field args with `?` or `{}`** to prevent shell expansion: `'field:type?'`, `'field:decimal{10,2}'`.
17
- - **Run `pu:res:conn` next** — without it the resource has no portal routes and is invisible.
17
+ - **Migrate, then run `pu:res:conn`.** Without `conn` the resource has no portal routes and is invisible. When `conn` writes a policy with attribute lists (no base policy to inherit from), it reads them off the table's columns; on an unmigrated table it logs an error and writes empty lists.
18
18
  - **Let auto-detection work.** Plutonium reads your model. Only declare `field`/`input`/`display`/`column` when overriding the default.
19
19
  - **Authorization is in policies, not `condition:` procs.** Use `condition` for UI state ("show this when published"). Use the policy's `permitted_attributes_for_*` for "who can see this".
20
20
  - **Custom actions require a policy method.** `action :publish` needs `def publish?` on the policy.
21
- - **`has_cents` virtual accessor** — reference `:price`, NEVER `:price_cents`, in policies and definitions.
21
+ - **`has_cents` virtual accessor**: reference `:price`, NEVER `:price_cents`, in policies and definitions.
22
22
 
23
23
  ---
24
24
 
25
- ## 🛑 Before you scaffold or edit: confirm the shape (ASK — don't infer)
25
+ ## 🛑 Before you scaffold or edit: confirm the shape (ASK: don't infer)
26
26
 
27
- "Add a Product" / "add a status field" is underspecified. Guess wrong and you scaffold into the wrong package, churn migrations, store money as a lossy float, reference a model that doesn't exist, or **clobber files the user has customized**. Resolve each — **by inspecting (next section), not guessing** — then restate the resolved shape and confirm:
27
+ "Add a Product" / "add a status field" is underspecified. Guess wrong and you scaffold into the wrong package, churn migrations, store money as a lossy float, reference a model that doesn't exist, or **clobber files the user has customized**. Resolve each, **by inspecting (next section), not guessing**, then restate the resolved shape and confirm:
28
28
 
29
29
  1. **New resource, or editing an existing one?** Existing ⇒ **NEVER re-run `pu:res:scaffold`** (it overwrites the customized model/definition/policy/controller). Add an incremental migration + hand-edit. Confirm by reading the files *first*.
30
30
  2. **Destination & portal.** `--dest=main_app` or a package? Which portal does `pu:res:conn` wire it to? Both are **required** and unguessable from the request.
31
- 3. **Field types — and the money question.** A monetary field ⇒ `has_cents` (`price_cents:integer` + `has_cents :price_cents`, reference `:price`), **never a bare `decimal`**. Enums, attachments (`:attachment`/`:attachments`), rich text, references each have specific syntax (§ Field Type Syntax). Confirm types rather than inventing them.
32
- 4. **Referenced associations must already exist.** `category:belongs_to` silently targets a `Category` — if that model isn't there, scaffold it first.
33
- 5. **Beyond columns:** does it need search / filters / scopes / custom or bulk actions? Those live in the definition + policy, not the scaffold — name them now so you don't half-build.
31
+ 3. **Field types, and the money question.** A monetary field ⇒ `has_cents` (`price_cents:integer` + `has_cents :price_cents`, reference `:price`), **never a bare `decimal`**. Enums, attachments (`:attachment`/`:attachments`), rich text, references each have specific syntax (§ Field Type Syntax). Confirm types rather than inventing them.
32
+ 4. **Referenced associations must already exist.** `category:belongs_to` silently targets a `Category`; if that model isn't there, scaffold it first.
33
+ 5. **Beyond columns:** does it need search / filters / scopes / custom or bulk actions? Those live in the definition + policy, not the scaffold; name them now so you don't half-build.
34
34
 
35
35
  **Never emit applied scaffold commands from a guessed `--dest`, portal, or money-shape.** Confirm or read them first; fall back to `AskUserQuestion` only for product choices you can't read off the code (which portal, is `price` money). The decisions compound: *existing+customized ⇒ migration not scaffold*; *money ⇒ `has_cents` + `:price` in the policy*; *new reference ⇒ target model must exist*.
36
36
 
37
- ## ✅ Before you touch files: verify the ground truth (CHECK — read it, don't ask for it)
37
+ ## ✅ Before you touch files: verify the ground truth (CHECK: read it, don't ask for it)
38
38
 
39
- You have file access — **use it.** "Paste me the model" is a fallback for when you genuinely can't read the repo, not the default.
39
+ You have file access: **use it.** "Paste me the model" is a fallback for when you genuinely can't read the repo, not the default.
40
40
 
41
41
  | Check | How | Why it matters |
42
42
  |---|---|---|
@@ -49,7 +49,7 @@ You have file access — **use it.** "Paste me the model" is a fallback for when
49
49
 
50
50
  Inspect with your own tools **before** proposing commands or edits.
51
51
 
52
- ## 🛠 Use the generator — and don't clobber
52
+ ## 🛠 Use the generator, and don't clobber
53
53
 
54
54
  Never hand-write the initial model, migration, policy, definition, or controller. Reach for the generator; quote args with `?`/`{}`; pass `--dest=`.
55
55
 
@@ -57,23 +57,32 @@ Never hand-write the initial model, migration, policy, definition, or controller
57
57
  |---|---|---|
58
58
  | New resource | `pu:res:scaffold Model field:type … --dest=` | `--dest` confirmed; referenced models exist |
59
59
  | Connect to a portal | `pu:res:conn Model --dest=portal` | Migrations are run |
60
- | Regenerate model from columns | `pu:res:scaffold Model --no-migration` | ⚠ **regenerates the model file** — review the diff; overwrites customizations |
61
- | Add a field to an **existing, customized** resource | `rails g migration AddXToYs …` + hand-edit model/definition/policy | This resource was already scaffolded — re-scaffolding clobbers it |
60
+ | Portal-specific policy or definition | `pu:res:conn Model --dest=portal --policy` (and/or `--definition`) | Base policy/definition exists; the override subclasses it |
61
+ | Regenerate model from columns | `pu:res:scaffold Model --no-migration` | ⚠ **regenerates the model file**: review the diff; overwrites customizations |
62
+ | Add a field to an **existing, customized** resource | `rails g migration AddXToYs …` + hand-edit model/definition/policy | This resource was already scaffolded; re-scaffolding clobbers it |
62
63
 
63
64
  ---
64
65
 
65
- # Part 1 — Creating a Resource
66
+ # Part 1: Creating a Resource
66
67
 
67
68
  ## Quick checklist
68
69
 
69
70
  1. Pick destination: `--dest=main_app` or `--dest=package_name`.
70
71
  2. Run `rails g pu:res:scaffold ResourceName field:type ... --dest=<dest>`.
71
- 3. Review the generated migration — add cascade deletes, composite indexes, defaults.
72
+ 3. Review the generated migration: add cascade deletes, composite indexes, defaults.
72
73
  4. `rails db:prepare`.
73
74
  5. `rails g pu:res:conn ResourceName --dest=<portal_name>`.
74
75
  6. Customize the policy's `permitted_attributes_for_*` as needed.
75
76
  7. Open the portal route in the browser.
76
77
 
78
+ **`permitted_associations` is checked per portal.** Each association listed there becomes a show-page tab, and the child model must be a registered resource in the portal rendering the page (`registered_resources` is the current engine's register). A base policy shared by several portals that names a child only one of them registers breaks the show page in the others:
79
+
80
+ ```
81
+ ArgumentError: Catalog::Product#reviews defined in #permitted_associations, but Catalog::Review is not a registered resource
82
+ ```
83
+
84
+ Register the child in every portal that uses the policy, or move the association into a portal-specific policy (`pu:res:conn Model --dest=portal --policy`, then `def permitted_associations = [*super, :reviews]`). See [[plutonium-behavior]].
85
+
77
86
  ## Command Syntax
78
87
 
79
88
  ```bash
@@ -194,9 +203,9 @@ price_cents:integer # use with has_cents in model
194
203
 
195
204
  ## Generator Options
196
205
 
197
- - `--dest=DESTINATION` — `main_app` or `package_name` (**required**)
198
- - `--no-model` — skip model file
199
- - `--no-migration` — skip migration
206
+ - `--dest=DESTINATION`: `main_app` or `package_name` (**required**)
207
+ - `--no-model`: skip model file
208
+ - `--no-migration`: skip migration
200
209
 
201
210
  For existing models that already include `Plutonium::Resource::Record`:
202
211
 
@@ -204,7 +213,7 @@ For existing models that already include `Plutonium::Resource::Record`:
204
213
  rails g pu:res:scaffold Post --no-migration --dest=main_app
205
214
  ```
206
215
 
207
- Run with no fields to auto-import from `model.content_columns` (regenerates the model file — review the diff).
216
+ Run with no fields to auto-import from `model.content_columns` (regenerates the model file; review the diff).
208
217
 
209
218
  ## What Gets Generated
210
219
 
@@ -239,6 +248,8 @@ For non-trivial defaults, edit the migration directly:
239
248
  t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
240
249
  ```
241
250
 
251
+ **Migration version: copy the app's form.** The generators (and `rails g migration`) stamp `ActiveRecord::Migration[<current version>]`, the Rails version that ran them. When you hand-write or edit a migration, open a neighbouring one and match its superclass rather than typing a version. An engine or gem whose test app runs under several Rails versions (Appraisal) often uses `ActiveRecord::Migration[[Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join(".").to_f]` or a pinned older version, and a hard-coded `[8.1]` raises on load under Rails 7.x or 8.0.
252
+
242
253
  ## Examples
243
254
 
244
255
  ```bash
@@ -268,7 +279,7 @@ rails g pu:res:scaffold Comment \
268
279
 
269
280
  ---
270
281
 
271
- # Part 2 — The Model Layer
282
+ # Part 2: The Model Layer
272
283
 
273
284
  ## What `Plutonium::Resource::Record` provides
274
285
 
@@ -296,7 +307,7 @@ end
296
307
 
297
308
  ## Section Order
298
309
 
299
- The scaffold lays out resource models in a strict order — keep new code in the right section so files stay scannable:
310
+ The scaffold lays out resource models in a strict order: keep new code in the right section so files stay scannable:
300
311
 
301
312
  1. Concerns (`include`)
302
313
  2. Constants (`TYPES = {...}.freeze`)
@@ -370,7 +381,7 @@ product.price = 10.999
370
381
  product.price_cents # => 1099
371
382
  ```
372
383
 
373
- **Critical: in policies and definitions, reference the virtual accessor (`:price`), NOT the column (`:price_cents`).** Generators sometimes emit `_cents` in the policy — fix by hand:
384
+ **Critical: in policies and definitions, reference the virtual accessor (`:price`), NOT the column (`:price_cents`).** Generators sometimes emit `_cents` in the policy, so fix by hand:
374
385
 
375
386
  ```ruby
376
387
  # Policy
@@ -409,7 +420,7 @@ post.remove_tag_sgid("...") # collection: remove
409
420
 
410
421
  ## URL Routing
411
422
 
412
- `path_parameter` and `dynamic_path_parameter` are **class-level macros** (private class methods) — call them in the class body, not as instance methods.
423
+ `path_parameter` and `dynamic_path_parameter` are **class-level macros** (private class methods): call them in the class body, not as instance methods.
413
424
 
414
425
  ```ruby
415
426
  # Default: numeric id
@@ -457,11 +468,11 @@ User.has_many_attached_field_names
457
468
 
458
469
  ---
459
470
 
460
- # Part 3 — The Definition Layer
471
+ # Part 3: The Definition Layer
461
472
 
462
473
  Definitions configure **how** a resource is rendered and interacted with.
463
474
 
464
- 🚨 **Do NOT declare a `field` / `input` / `display` / `column` unless you are overriding an auto-detected default.** Plutonium reads the model and renders every attribute automatically — type, label, form widget, display formatter, column. Declaring it again with no new options is dead code; declaring it with the same `as:` Plutonium already inferred is dead code; listing every field "for completeness" is dead code. If the only reason you're adding a line is "so the field shows up", delete it — it already shows up. Declare ONLY when you need: a different type (`as: :markdown`), a custom option (`hint:`, `placeholder:`, `wrapper:`), a `condition:`, a custom block, or a custom component.
475
+ 🚨 **Do NOT declare a `field` / `input` / `display` / `column` unless you are overriding an auto-detected default.** Plutonium reads the model and renders every attribute automatically: type, label, form widget, display formatter, column. Declaring it again with no new options is dead code; declaring it with the same `as:` Plutonium already inferred is dead code; listing every field "for completeness" is dead code. If the only reason you're adding a line is "so the field shows up", delete it; it already shows up. Declare ONLY when you need: a different type (`as: :markdown`), a custom option (`hint:`, `placeholder:`, `wrapper:`), a `condition:`, a custom block, or a custom component.
465
476
 
466
477
  File locations:
467
478
 
@@ -535,7 +546,7 @@ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) ar
535
546
  | Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
536
547
  | Rich Text | `:markdown` (EasyMDE) |
537
548
  | Numeric | `:number`, `:integer`, `:decimal`, `:range` |
538
- | Boolean | `:toggle` / `:switch` (switch — **default** for boolean columns), `:boolean` (plain checkbox) |
549
+ | Boolean | `:toggle` / `:switch` (switch, **default** for boolean columns), `:boolean` (plain checkbox) |
539
550
  | Date/Time | `:date`, `:time`, `:datetime` |
540
551
  | Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
541
552
  | Files | `:file`, `:uppy`, `:attachment` |
@@ -548,7 +559,7 @@ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) ar
548
559
 
549
560
  #### Auto-inferred display formatting
550
561
 
551
- These render automatically — declare an `as:` only to override or pass options:
562
+ These render automatically: declare an `as:` only to override or pass options:
552
563
 
553
564
  | Column | Renders as | Notes |
554
565
  |--------|-----------|-------|
@@ -592,7 +603,7 @@ input :title,
592
603
  input :category, as: :select, choices: %w[Tech Business Lifestyle]
593
604
  input :status, as: :select, choices: Post.statuses.keys
594
605
 
595
- # Dynamic — must use a block
606
+ # Dynamic: must use a block
596
607
  input :author do |f|
597
608
  f.select_tag choices: User.active.pluck(:name, :id)
598
609
  end
@@ -624,24 +635,24 @@ A surface's own `condition:` wins; otherwise the surface falls back to the `fiel
624
635
 
625
636
  ## Options That Vary Per Render
626
637
 
627
- Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
638
+ Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL: `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
628
639
 
629
640
  ```ruby
630
641
  input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
631
642
  input :notes, placeholder: -> { "Updated #{Time.current.year}" }
632
643
  ```
633
644
 
634
- - `-> { … }` is called as-is, keeping its own binding — it means what it reads like where you wrote it; nothing rebinds `self`. That is what makes `choices: -> { reviewer_choices }` work inside an interaction's `customize_inputs` (private helpers included).
635
- - `->(form) { … }` gets the form — `object` (the record), `params`, view helpers.
645
+ - `-> { … }` is called as-is, keeping its own binding: it means what it reads like where you wrote it; nothing rebinds `self`. That is what makes `choices: -> { reviewer_choices }` work inside an interaction's `customize_inputs` (private helpers included).
646
+ - `->(form) { … }` gets the form: `object` (the record), `params`, view helpers.
636
647
 
637
- Same rule on wizard steps — but a step block closes over an internal field recorder, so options there must take the form: `->(form) { form.wizard.anchor.tiers }`. See [[plutonium-wizard]].
648
+ Same rule on wizard steps, but a step block closes over an internal field recorder, so options there must take the form: `->(form) { form.wizard.anchor.tiers }`. See [[plutonium-wizard]].
638
649
 
639
- **`condition:` is not an option — it follows a different rule, for a reason.** An option asks "what value should this have?", so it may not care about the render and defaults to meaning what it reads like. `condition:` asks "should this render *here, now*?" — a question about the render context by definition. So it always runs **against** that context and reads it with no argument, where "context" is whatever is rendering:
650
+ **`condition:` is not an option: it follows a different rule, for a reason.** An option asks "what value should this have?", so it may not care about the render and defaults to meaning what it reads like. `condition:` asks "should this render *here, now*?", a question about the render context by definition. So it always runs **against** that context and reads it with no argument, where "context" is whatever is rendering:
640
651
 
641
652
  ```ruby
642
653
  input :notes, condition: -> { object.published? } # the form
643
654
  display :audit_log, condition: -> { current_user.admin? } # the display component
644
- step :billing, condition: -> { data.plan.tier == "pro" } # the wizard — no form exists yet
655
+ step :billing, condition: -> { data.plan.tier == "pro" } # the wizard, no form exists yet
645
656
  ```
646
657
 
647
658
  It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.
@@ -689,7 +700,7 @@ class QuestionDefinition < ResourceDefinition
689
700
  choices: %w[text choice scale],
690
701
  pre_submit: true
691
702
 
692
- # No `as:` — types are auto-detected from the model. We only declare to add `condition:`.
703
+ # No `as:`: types are auto-detected from the model. We only declare to add `condition:`.
693
704
  input :max_length, condition: -> { object.question_type == "text" }
694
705
  input :choices, condition: -> { object.question_type == "choice" }
695
706
  input :min_value, condition: -> { object.question_type == "scale" }
@@ -716,7 +727,7 @@ Tips:
716
727
 
717
728
  ## Custom Rendering
718
729
 
719
- **Display block — return any component:**
730
+ **Display block: return any component:**
720
731
 
721
732
  ```ruby
722
733
  display :status do |field|
@@ -724,7 +735,7 @@ display :status do |field|
724
735
  end
725
736
  ```
726
737
 
727
- **Input block — must use form builder methods:**
738
+ **Input block: must use form builder methods:**
728
739
 
729
740
  ```ruby
730
741
  input :birth_date do |f|
@@ -762,14 +773,14 @@ end
762
773
 
763
774
  See [[plutonium-ui]] for writing custom Phlex components.
764
775
 
765
- **Custom component classes** (Phlex components — see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)` — subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
776
+ **Custom component classes** (Phlex components, see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)`: subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
766
777
 
767
778
  ```ruby
768
779
  input :color_picker, as: ColorPickerComponent
769
780
  display :chart, as: ChartComponent
770
781
  ```
771
782
 
772
- 🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate — it raises `ArgumentError`. Build it in a block instead:
783
+ 🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate; it raises `ArgumentError`. Build it in a block instead:
773
784
 
774
785
  ```ruby
775
786
  display :card do |field|
@@ -783,10 +794,10 @@ end
783
794
  column :title, align: :start # :start (default), :center, :end
784
795
  column :amount, align: :end
785
796
 
786
- # formatter — receives just the value
797
+ # formatter: receives just the value
787
798
  column :price, formatter: ->(v) { "$%.2f" % v if v }
788
799
 
789
- # block — receives the full record
800
+ # block: receives the full record
790
801
  column :full_name do |record|
791
802
  "#{record.first_name} #{record.last_name}"
792
803
  end
@@ -841,7 +852,7 @@ end
841
852
  |--------|-------------|
842
853
  | `limit` | Max records (auto-detected from model, default 10) |
843
854
  | `allow_destroy` | Show delete checkbox (auto-detected) |
844
- | `update_only` | Hide "Add" button — only edit existing |
855
+ | `update_only` | Hide "Add" button, only edit existing |
845
856
  | `description` | Help text above section |
846
857
  | `condition` | Proc to show/hide |
847
858
  | `using` | Another Definition class |
@@ -857,7 +868,7 @@ end
857
868
 
858
869
  ## Structured Inputs
859
870
 
860
- `structured_input` collects a **classless** group of fields — a single hash, or
871
+ `structured_input` collects a **classless** group of fields: a single hash, or
861
872
  (with `repeat:`) an array of hashes. No association or model class is involved.
862
873
  On resources the value is stored in a **JSON/jsonb column**; use it when you
863
874
  want structured data in a JSON column rather than a real association (which is
@@ -901,14 +912,14 @@ blank rows are dropped, `_destroy` stripped).
901
912
 
902
913
  ### Gotchas
903
914
 
904
- - The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed — the value assigns directly.
915
+ - The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed; the value assigns directly.
905
916
  - **Unlike `nested_input`, you DO permit the column name** in `permitted_attributes_for_*` (it's a regular attribute on a JSON column).
906
- - `repeat: 1` is "array, max one row" — **not** the single form. Presence of `repeat:` always means an array.
907
- - Rows are positional plain hashes — **no ids, no per-row class, no type coercion**.
917
+ - `repeat: 1` is "array, max one row", **not** the single form. Presence of `repeat:` always means an array.
918
+ - Rows are positional plain hashes: **no ids, no per-row class, no type coercion**.
908
919
  - **No automatic validation.** Classless ⇒ nothing to attach `validates` to. `required:` and a select's `choices:` are **client-side only**, not enforced on the server. To enforce, add a model `validate` (resource) or a `validate` on the interaction (ActiveModel, checked before `execute`).
909
920
  - **`as: :select` drops unknown values.** If a stored value isn't in `choices:`, the `<select>` renders blank and **saving overwrites it with `nil`** (standard `<select>` behaviour). Keep `choices:` a stable superset or use free text when values can drift.
910
- - Inside repeater rows, prefer **native** field types (string, number, text, native `select`, checkbox). JS-enhanced inputs (slim-select, flatpickr, easymde, uppy, intl-tel) transform the DOM and may not survive the repeater's clone-by-innerHTML — verify before relying on them.
911
- - Same DSL works on **interactions** (see [[plutonium-behavior]] › Interactions) — there it backs an ActiveModel attribute reaching `execute`.
921
+ - Inside repeater rows, prefer **native** field types (string, number, text, native `select`, checkbox). JS-enhanced inputs (slim-select, flatpickr, easymde, uppy, intl-tel) transform the DOM and may not survive the repeater's clone-by-innerHTML; verify before relying on them.
922
+ - Same DSL works on **interactions** (see [[plutonium-behavior]] › Interactions); there it backs an ActiveModel attribute reaching `execute`.
912
923
 
913
924
  ## File Uploads
914
925
 
@@ -925,9 +936,9 @@ input :documents, as: :uppy,
925
936
 
926
937
  Inside `condition` procs and block-form `input`/`display`:
927
938
 
928
- - `object` — the record
939
+ - `object`: the record
929
940
  - `current_user`
930
- - `current_parent` — for nested resources
941
+ - `current_parent`: for nested resources
931
942
  - `request`, `params`
932
943
  - All helper methods
933
944
 
@@ -964,7 +975,7 @@ class PostDefinition < ResourceDefinition
964
975
  breadcrumbs true
965
976
  show_page_breadcrumbs false
966
977
 
967
- # Custom page classes — inherit from the parent's nested class
978
+ # Custom page classes: inherit from the parent's nested class
968
979
  class IndexPage < IndexPage
969
980
  def view_template(&block)
970
981
  div(class: "custom-header") { h1 { "Custom" } }
@@ -988,7 +999,7 @@ end
988
999
 
989
1000
  ## Form Layout (`form_layout`)
990
1001
 
991
- Group form fields into sections **declaratively in the definition** — no `Form` subclass, no view code. Prefer this over hand-rolling a `section` helper in a custom `form_template`.
1002
+ Group form fields into sections **declaratively in the definition**, no `Form` subclass, no view code. Prefer this over hand-rolling a `section` helper in a custom `form_template`.
992
1003
 
993
1004
  ```ruby
994
1005
  class PostDefinition < ResourceDefinition
@@ -1002,18 +1013,18 @@ class PostDefinition < ResourceDefinition
1002
1013
  end
1003
1014
  ```
1004
1015
 
1005
- - **Layout references field KEYS only** — all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
1006
- - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option — take a `form` argument to read the render context.
1016
+ - **Layout references field KEYS only**: all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
1017
+ - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option: take a `form` argument to read the render context.
1007
1018
  - ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
1008
- - **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped — never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
1009
- - **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. A section whose fields are **all** hidden by their own `condition:` on this render drops too (the hidden fields are still recorded on the form). A section's own `condition:` hides it as a unit regardless of its fields.
1010
- - **Works on interactions too** (`Plutonium::Interaction::Base`) — groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
1019
+ - **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped, never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
1020
+ - **🚨 Zero-field sections drop entirely**: no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. A section whose fields are **all** hidden by their own `condition:` on this render drops too (the hidden fields are still recorded on the form). A section's own `condition:` hides it as a unit regardless of its fields.
1021
+ - **Works on interactions too** (`Plutonium::Interaction::Base`): groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
1011
1022
 
1012
1023
  Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
1013
1024
 
1014
1025
  ## Display Layout (`display_layout`)
1015
1026
 
1016
- The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field and all-condition-hidden sections dropped) — applied to the show page's fields instead of the form's.
1027
+ The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field and all-condition-hidden sections dropped), applied to the show page's fields instead of the form's.
1017
1028
 
1018
1029
  ```ruby
1019
1030
  class PostDefinition < ResourceDefinition
@@ -1025,14 +1036,14 @@ class PostDefinition < ResourceDefinition
1025
1036
  end
1026
1037
  ```
1027
1038
 
1028
- - **Declare both independently.** `form_layout` and `display_layout` are separate registries — a resource can group its form one way and its show page another, or declare only one. Neither inherits from the other.
1029
- - **🚨 No `columns:`** — unlike `form_layout`, it **raises**. Every display section shares one responsive grid; field width is a per-field concern: `display :x, wrapper: {class: "col-span-2"}` (works identically inside a section and outside one). Raising rather than ignoring means a copied `form_layout` block fails loudly instead of silently doing nothing.
1030
- - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as `form_layout` minus `columns:`. `collapsible: true, collapsed: true` works exactly as it does on forms. Every option except `condition:` may be a **proc**, resolved at render under the same arity rule as the form: take a `display` argument to read `object`.
1031
- - **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel) — see below.
1039
+ - **Declare both independently.** `form_layout` and `display_layout` are separate registries: a resource can group its form one way and its show page another, or declare only one. Neither inherits from the other.
1040
+ - **🚨 No `columns:`.** Unlike `form_layout`, it **raises**. Every display section shares one responsive grid; field width is a per-field concern: `display :x, wrapper: {class: "col-span-2"}` (works identically inside a section and outside one). Raising rather than ignoring means a copied `form_layout` block fails loudly instead of silently doing nothing.
1041
+ - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:`, the same set as `form_layout` minus `columns:`. `collapsible: true, collapsed: true` works exactly as it does on forms. Every option except `condition:` may be a **proc**, resolved at render under the same arity rule as the form: take a `display` argument to read `object`.
1042
+ - **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel); see below.
1032
1043
 
1033
1044
  ## Page Width (`page_width`)
1034
1045
 
1035
- Detail-style pages — the show page and resource forms — are width-constrained by default. Inputs and values stretch to their container, so at full content width you get ~1200px-long lines. Index/table pages are NOT affected.
1046
+ Detail-style pages (the show page and resource forms) are width-constrained by default. Inputs and values stretch to their container, so at full content width you get ~1200px-long lines. Index/table pages are NOT affected.
1036
1047
 
1037
1048
  ```ruby
1038
1049
  Plutonium.configure { |c| c.default_page_width = :md } # global default (:md)
@@ -1045,12 +1056,12 @@ end
1045
1056
  ```
1046
1057
 
1047
1058
  - **Sizes**: `:sm` `:md` `:lg` `:xl` `:full`. `:full` means no constraint. An unknown value **raises** at declaration.
1048
- - **🚨 Tokens are relative to their surface** — the same *names* modals use, but NOT the same widths. Page `:md` is 896px; a centered modal's `:md` is 576px and a slideover's is 480px. A "small page" is deliberately larger than a "small dialog". Modals also have `:auto`; pages don't (nothing to hug).
1059
+ - **🚨 Tokens are relative to their surface**: the same *names* modals use, but NOT the same widths. Page `:md` is 896px; a centered modal's `:md` is 576px and a slideover's is 480px. A "small page" is deliberately larger than a "small dialog". Modals also have `:auto`; pages don't (nothing to hug).
1049
1060
  - **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
1050
1061
  - **Inherits** to subclasses, so a portal-specific definition keeps the parent's width unless it overrides.
1051
- - **Modals are unaffected** — the dialog sets its own width (`modal_size`).
1062
+ - **Modals are unaffected**: the dialog sets its own width (`modal_size`).
1052
1063
  - **Interactions support it too** (`Plutonium::Interaction::Base`), for interactive actions rendered as standalone pages.
1053
- - **Wizards are on their own axis** — `Plutonium.configuration.wizards.width` (default `:md`), overridden per wizard with `width`. It does NOT follow `default_page_width`, so changing resource page width leaves wizards untouched.
1064
+ - **Wizards are on their own axis**: `Plutonium.configuration.wizards.width` (default `:md`), overridden per wizard with `width`. It does NOT follow `default_page_width`, so changing resource page width leaves wizards untouched.
1054
1065
 
1055
1066
  ## Metadata Panel (show page)
1056
1067
 
@@ -1060,12 +1071,12 @@ Declares fields rendered in the show page's right-side aside as label/value rows
1060
1071
  metadata :author, :state, :created_at, :updated_at
1061
1072
  ```
1062
1073
 
1063
- - **Opt-in** — no call → show page is full-width with no aside.
1064
- - **Policy-aware** — fields the user can't see disappear; panel auto-hides if nothing's permitted.
1065
- - **Deduplicated** — listed fields are removed from the main details card (and from any `display_layout` section).
1066
- - **Responsive** — side-by-side at `lg+`, stacked below.
1074
+ - **Opt-in**: no call → show page is full-width with no aside.
1075
+ - **Policy-aware**: fields the user can't see disappear; panel auto-hides if nothing's permitted.
1076
+ - **Deduplicated**: listed fields are removed from the main details card (and from any `display_layout` section).
1077
+ - **Responsive**: side-by-side at `lg+`, stacked below.
1067
1078
  - **In a modal it stacks below the details**, not beside them: the rail is a fixed-width column on a *viewport* breakpoint, so in a dialog it would split regardless of how narrow the dialog is and crush the main column.
1068
- - **A kanban card's modal drops metadata entirely** — the fields are hidden, not folded into the main card.
1079
+ - **A kanban card's modal drops metadata entirely**: the fields are hidden, not folded into the main card.
1069
1080
 
1070
1081
  Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
1071
1082
 
@@ -1075,7 +1086,7 @@ Resources can offer both Table and Grid views; user choice persists per-resource
1075
1086
 
1076
1087
  ```ruby
1077
1088
  class UserDefinition < ResourceDefinition
1078
- # No `index_views :table, :grid` needed — `grid_fields` auto-enables :grid alongside the default :table.
1089
+ # No `index_views :table, :grid` needed, `grid_fields` auto-enables :grid alongside the default :table.
1079
1090
  grid_fields(
1080
1091
  image: :avatar, # ActiveStorage, Shrine, or URL
1081
1092
  header: :name, # falls back to to_label
@@ -1085,7 +1096,7 @@ class UserDefinition < ResourceDefinition
1085
1096
  footer: :last_seen_at # falls back to :created_at; `false` to omit
1086
1097
  )
1087
1098
 
1088
- default_index_view :grid # optional — initial view when no cookie
1099
+ default_index_view :grid # optional: initial view when no cookie
1089
1100
  grid_layout :media # :compact (default) or :media
1090
1101
  grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
1091
1102
  end
@@ -1107,23 +1118,23 @@ All grid slots are optional; slots pointing at unpermitted fields collapse silen
1107
1118
 
1108
1119
  ## Drag-to-Reorder (`positioned_on` + `position_on`)
1109
1120
 
1110
- Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three** — the model says how positions are stored, the definition (and a kanban board) says the UI is orderable:
1121
+ Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three**: the model says how positions are stored, the definition (and a kanban board) says the UI is orderable:
1111
1122
 
1112
1123
  ```ruby
1113
- # Migration — t.position emits decimal(16,8), tuned for fractional ordering.
1124
+ # Migration: t.position emits decimal(16,8), tuned for fractional ordering.
1114
1125
  create_table :tasks do |t|
1115
1126
  t.string :status, null: false, default: "todo"
1116
1127
  t.position
1117
1128
  t.index [:status, :position] # match the scope attribute
1118
1129
  end
1119
1130
 
1120
- # Model — storage
1131
+ # Model: storage
1121
1132
  class Task < ApplicationRecord
1122
1133
  include Plutonium::Positioning::Model # NOT Plutonium::Positioning
1123
1134
  positioned_on :position, scope: :status # scope: nil = one global ordering
1124
1135
  end
1125
1136
 
1126
- # Definition — "this UI can be reordered". Never restates the column or the scope.
1137
+ # Definition: "this UI can be reordered". Never restates the column or the scope.
1127
1138
  class TaskDefinition < ResourceDefinition
1128
1139
  position_on
1129
1140
  end
@@ -1132,45 +1143,45 @@ end
1132
1143
  Task.backfill_positions!(order: :created_at)
1133
1144
  ```
1134
1145
 
1135
- - **`include Plutonium::Positioning::Model`** — the concern used to be `Plutonium::Positioning` itself. A bare `include Plutonium::Positioning` is now wrong (it's a pure namespace). Constants nested in an included concern join the model's constant lookup, so the old form let `Plutonium::Positioning::Config` shadow an app's own `::Config`.
1136
- - **`positioned_on` is required**, not just the include — without it there's no `before_create`, so every row is created with a `NULL` position. `position_on` raises at class-load if you forget.
1146
+ - **`include Plutonium::Positioning::Model`**: the concern used to be `Plutonium::Positioning` itself. A bare `include Plutonium::Positioning` is now wrong (it's a pure namespace). Constants nested in an included concern join the model's constant lookup, so the old form let `Plutonium::Positioning::Config` shadow an app's own `::Config`.
1147
+ - **`positioned_on` is required**, not just the include; without it there's no `before_create`, so every row is created with a `NULL` position. `position_on` raises at class-load if you forget.
1137
1148
 
1138
1149
  ### `position_on` forms and modes
1139
1150
 
1140
1151
  | Form | Mode | Notes |
1141
1152
  |---|---|---|
1142
- | `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this** — it cannot disagree with the model. |
1153
+ | `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this**: it cannot disagree with the model. |
1143
1154
  | `position_on :sort_order` | A | Must **match** the model's column, else `ArgumentError` at class-load |
1144
- | `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch — another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
1145
- | `position_on false` | C (disabled) | No ordering, no route — the endpoint 404s |
1155
+ | `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch: another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
1156
+ | `position_on false` | C (disabled) | No ordering, no route, the endpoint 404s |
1146
1157
 
1147
- **Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested — it just costs you semantics Mode A handles.
1158
+ **Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested; it just costs you semantics Mode A handles.
1148
1159
 
1149
- ### Mode B — what the framework stops doing
1160
+ ### Mode B: what the framework stops doing
1150
1161
 
1151
1162
  Mode B block receives a `Plutonium::Positioning::Move`: `record`, `prev`, `next`, `index` (0-based, **relative to the visible page**), `column` (kanban only, `nil` on tables/grids). Called with `call`, not `instance_exec`.
1152
1163
 
1153
1164
  Because the write is opaque, three Mode A behaviours are **not** provided:
1154
1165
 
1155
- - **No hidden boundary resolution** — `resolve_position_boundaries` returns early unless the config delegates, so the block gets the client's viewport verbatim, `nil`s and all.
1156
- - **No server-side foreign-sort rejection** — Mode A rejects a drop under a foreign sort with 422 before writing; Mode B relies on the client-side gate only.
1157
- - **Always a full repaint** — never 204. Gems like `acts_as_list` renumber the whole group on every move, so the client's optimistic DOM is stale by definition.
1166
+ - **No hidden boundary resolution**: `resolve_position_boundaries` returns early unless the config delegates, so the block gets the client's viewport verbatim, `nil`s and all.
1167
+ - **No server-side foreign-sort rejection**: Mode A rejects a drop under a foreign sort with 422 before writing; Mode B relies on the client-side gate only.
1168
+ - **Always a full repaint**: never 204. Gems like `acts_as_list` renumber the whole group on every move, so the client's optimistic DOM is stale by definition.
1158
1169
 
1159
1170
  ### Migrating off `acts_as_list` to Mode A
1160
1171
 
1161
- 1. **Change the column** — `acts_as_list` uses contiguous integers; Plutonium uses fractional decimals (`t.position` emits `decimal(16,8)`; an integer column would round every midpoint onto a neighbour). `t.position` *adds* a column, so an existing one needs `change_column :tasks, :position, :decimal, precision: 16, scale: 8`.
1162
- 2. **Swap the macro** — drop `acts_as_list scope: [:status]`, add `include Plutonium::Positioning::Model` + `positioned_on :position, scope: :status` (bare Symbol; the Array trap is gem-specific).
1163
- 3. **Backfill** — `Task.backfill_positions!(order: :position)` numbers each scope group `1.0, 2.0, …` in the gem's existing order. `update_column`, no callbacks/validations/`updated_at` — run it once from a migration or `rails runner`.
1172
+ 1. **Change the column**: `acts_as_list` uses contiguous integers; Plutonium uses fractional decimals (`t.position` emits `decimal(16,8)`; an integer column would round every midpoint onto a neighbour). `t.position` *adds* a column, so an existing one needs `change_column :tasks, :position, :decimal, precision: 16, scale: 8`.
1173
+ 2. **Swap the macro**: drop `acts_as_list scope: [:status]`, add `include Plutonium::Positioning::Model` + `positioned_on :position, scope: :status` (bare Symbol; the Array trap is gem-specific).
1174
+ 3. **Backfill**: `Task.backfill_positions!(order: :position)` numbers each scope group `1.0, 2.0, …` in the gem's existing order. `update_column`, no callbacks/validations/`updated_at`; run it once from a migration or `rails runner`.
1164
1175
  4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.
1165
1176
 
1166
1177
  ### Staying on `acts_as_list` (the harder road)
1167
1178
 
1168
1179
  For when the gem is not yours to remove. This recipe is correct and tested against the real gem (`test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb`).
1169
1180
 
1170
- 🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size) — measured: dragging rank 25 into the middle of page 2 lands it at **rank 2**, and a page-2 top drop lands at **rank 1**. Same failures on a filtered list.
1181
+ 🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size). Measured: dragging rank 25 into the middle of page 2 lands it at **rank 2**, and a page-2 top drop lands at **rank 1**. Same failures on a filtered list.
1171
1182
 
1172
1183
  ```ruby
1173
- # Keeping acts_as_list. NOTE scope: [:status] — a bare Symbol scope is run
1184
+ # Keeping acts_as_list. NOTE scope: [:status], a bare Symbol scope is run
1174
1185
  # through acts_as_list's `idify`, which turns :status into :status_id and makes
1175
1186
  # every create raise NoMethodError.
1176
1187
  class Task < ApplicationRecord
@@ -1186,7 +1197,7 @@ class TaskDefinition < ResourceDefinition
1186
1197
  # Removing the record shifts prev up one when the record was above it.
1187
1198
  (record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
1188
1199
  elsif move.next
1189
- # Blank prev means "nothing above me ON MY SCREEN" — rows may still sit
1200
+ # Blank prev means "nothing above me ON MY SCREEN"; rows may still sit
1190
1201
  # above off-page or behind a filter, so anchor off next rather than 1.
1191
1202
  (record.position < move.next.position) ? move.next.position - 1 : move.next.position
1192
1203
  else
@@ -1198,7 +1209,7 @@ class TaskDefinition < ResourceDefinition
1198
1209
  end
1199
1210
  ```
1200
1211
 
1201
- `insert_at` calls `save`, not `save!` — a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
1212
+ `insert_at` calls `save`, not `save!`; a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
1202
1213
 
1203
1214
  ### 🚨 `position_on` expands to three things
1204
1215
 
@@ -1208,21 +1219,21 @@ default_sort <attr>, :asc # ⚠ ONLY when default_sort is still the frame
1208
1219
  action :reposition, hidden: true # route + policy predicate, no button
1209
1220
  ```
1210
1221
 
1211
- **The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on` — resolution is order-independent) to keep it; note the list then opens **not** draggable.
1222
+ **The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on`: resolution is order-independent) to keep it; note the list then opens **not** draggable.
1212
1223
 
1213
1224
  ### Behavior notes
1214
1225
 
1215
1226
  - **Dragging is offered only while the collection is sorted ascending by the position attribute** (and nothing else). Under any other sort the grip renders as a **link that applies that sort**, and the server rejects the drop with 422 before writing.
1216
1227
  - **`reposition?` policy predicate**, defaulting to `update?`. Gates both the drop and whether the grip renders per row. `index?` is also required (you must be able to see a list to reorder it).
1217
1228
  - **A kanban board inherits the definition's `position_on`** (lazily, so order in the class body doesn't matter); a `position_on` inside `kanban do…end` overrides it.
1218
- - **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent — but a rebalance renumbers every row in the table, not just that parent's.
1229
+ - **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent, but a rebalance renumbers every row in the table, not just that parent's.
1219
1230
  - Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
1220
1231
 
1221
1232
  Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
1222
1233
 
1223
1234
  ---
1224
1235
 
1225
- # Part 4 — Query: Search, Filters, Scopes, Sorting
1236
+ # Part 4: Query: Search, Filters, Scopes, Sorting
1226
1237
 
1227
1238
  ```ruby
1228
1239
  class PostDefinition < ResourceDefinition
@@ -1317,7 +1328,7 @@ default_scope :published # applied on initial load; "All" button clears it
1317
1328
 
1318
1329
  ### Conditional scopes (`condition:`)
1319
1330
 
1320
- Like `condition:` on actions and fields — define a scope but only **render its button** when a proc is truthy. The scope itself (and its URL) stays live; `condition:` only controls UI visibility.
1331
+ Like `condition:` on actions and fields: define a scope but only **render its button** when a proc is truthy. The scope itself (and its URL) stays live; `condition:` only controls UI visibility.
1321
1332
 
1322
1333
  ```ruby
1323
1334
  scope :admin_only, condition: -> { current_user.admin? }
@@ -1325,7 +1336,7 @@ scope :beta_feature, condition: -> { params[:beta] == "1" }
1325
1336
  scope :never_shown, condition: -> { false } # hides button but URL still works
1326
1337
  ```
1327
1338
 
1328
- The proc is evaluated against the view context — `current_user`, `params`, `request`, `allowed_to?` are all available directly. There is no `object`/`record` (scopes have no single-record context).
1339
+ The proc is evaluated against the view context: `current_user`, `params`, `request`, `allowed_to?` are all available directly. There is no `object`/`record` (scopes have no single-record context).
1329
1340
 
1330
1341
  🚨 **`condition:` is NOT authorization.** A hidden scope button still has a live URL. Use `condition:` for UI relevance ("show admins only this tab"). Use the policy's `relation_scope` for "who can see these records at all".
1331
1342
 
@@ -1353,7 +1364,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1353
1364
 
1354
1365
  ---
1355
1366
 
1356
- # Part 5 — Actions: Custom and Bulk
1367
+ # Part 5: Actions: Custom and Bulk
1357
1368
 
1358
1369
  ## Action Types
1359
1370
 
@@ -1365,7 +1376,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1365
1376
  | `bulk_action: true` | Selected records | Bulk operations |
1366
1377
  | `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |
1367
1378
 
1368
- 🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes — don't declare them manually:**
1379
+ 🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes, so don't declare them manually:**
1369
1380
 
1370
1381
  | Interaction declares | Inferred flags |
1371
1382
  |---|---|
@@ -1373,7 +1384,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1373
1384
  | `attribute :resources` (plural) | `bulk_action` |
1374
1385
  | neither | `resource_action` |
1375
1386
 
1376
- User-supplied flags override the inferred ones, but only **opt-out** makes sense for interactive actions — the interaction's `attribute :resource` / `attribute :resources` already fixes the action's semantic shape. Use opt-out to narrow where the button appears:
1387
+ User-supplied flags override the inferred ones, but only **opt-out** makes sense for interactive actions: the interaction's `attribute :resource` / `attribute :resources` already fixes the action's semantic shape. Use opt-out to narrow where the button appears:
1377
1388
 
1378
1389
  ```ruby
1379
1390
  # :resource interaction defaults to record_action + collection_record_action.
@@ -1402,11 +1413,11 @@ action :name,
1402
1413
  collection_record_action: true,
1403
1414
  bulk_action: true,
1404
1415
 
1405
- # Conditional visibility — display-only toggle, NOT authorization (see below).
1416
+ # Conditional visibility: display-only toggle, NOT authorization (see below).
1406
1417
  # `-> { false }` keeps the route live but hides the button (e.g. API-only).
1407
1418
  condition: -> { params[:beta] == "1" },
1408
1419
 
1409
- # Never renders anywhere — route + policy stay live. For endpoints reached by
1420
+ # Never renders anywhere: route + policy stay live. For endpoints reached by
1410
1421
  # a gesture rather than a button (see Hidden Actions below). NOT authorization.
1411
1422
  hidden: true,
1412
1423
 
@@ -1418,17 +1429,17 @@ action :name,
1418
1429
  confirmation: "Are you sure?",
1419
1430
  turbo_frame: "_top",
1420
1431
  route_options: {action: :foo},
1421
- modal: :slideover, # :slideover / :centered — overrides definition's modal mode
1422
- size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
1432
+ modal: :slideover, # :slideover / :centered, overrides definition's modal mode
1433
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
1423
1434
 
1424
- # HTML attributes — deep-merged over the framework's, author wins on every key
1435
+ # HTML attributes: deep-merged over the framework's, author wins on every key
1425
1436
  link: {target: "_blank", rel: "noopener"}, # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
1426
1437
  button: {data: {analytics: "x"}} # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
1427
1438
  ```
1428
1439
 
1429
1440
  ### HTML Attributes (`link:` / `button:`)
1430
1441
 
1431
- Per-element attribute bags for an action's rendered control. `link:` lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); `button:` lands on the `button_to` `<form>` element. The author wins on collisions — including `class:` (replaces, no token append) and `turbo_frame`. Pass `data:` as a hash: a scalar `data:` replaces the framework's data wholesale, dropping `turbo_confirm`/`turbo_frame`.
1442
+ Per-element attribute bags for an action's rendered control. `link:` lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); `button:` lands on the `button_to` `<form>` element. The author wins on collisions, including `class:` (replaces, no token append) and `turbo_frame`. Pass `data:` as a hash: a scalar `data:` replaces the framework's data wholesale, dropping `turbo_confirm`/`turbo_frame`.
1432
1443
 
1433
1444
  ```ruby
1434
1445
  action :documentation,
@@ -1441,29 +1452,29 @@ Both bags round-trip through `with(...)`: `defined_actions[:edit].with(link: {ta
1441
1452
 
1442
1453
  ### Conditional Actions (`condition:`)
1443
1454
 
1444
- Like `condition:` on inputs/displays/columns — define an action but render its **button** only when a runtime proc is truthy. The action and its route stay live either way; `condition:` only toggles the UI.
1455
+ Like `condition:` on inputs/displays/columns: define an action but render its **button** only when a runtime proc is truthy. The action and its route stay live either way; `condition:` only toggles the UI.
1445
1456
 
1446
- Headline use case: **expose an action's endpoint without a button** — one you call from the API, a webhook, or another service. Hide it with an always-falsy condition; the route still works:
1457
+ Headline use case: **expose an action's endpoint without a button**, one you call from the API, a webhook, or another service. Hide it with an always-falsy condition; the route still works:
1447
1458
 
1448
1459
  ```ruby
1449
1460
  # Defined and callable (API / programmatic), but no button anywhere:
1450
1461
  action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
1451
1462
 
1452
- # Per-record display state — object is the row/shown record:
1463
+ # Per-record display state: object is the row/shown record:
1453
1464
  action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
1454
1465
 
1455
1466
  # View/request-level toggle (feature flag, beta mode):
1456
1467
  action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
1457
1468
  ```
1458
1469
 
1459
- Inside the proc, `object`/`record` is the contextual record — the row/shown record for **record** and **collection-record** actions, **nil** for **resource** and **bulk** actions (guard with `object&.…` if shared). Every other call delegates to the **view context**: `current_user`, `current_parent`, `params`, `request`, `allowed_to?`, `resource_record!`, etc. `object` is evaluated per row in tables/grids, so per-record show/hide works there.
1470
+ Inside the proc, `object`/`record` is the contextual record: the row/shown record for **record** and **collection-record** actions, **nil** for **resource** and **bulk** actions (guard with `object&.…` if shared). Every other call delegates to the **view context**: `current_user`, `current_parent`, `params`, `request`, `allowed_to?`, `resource_record!`, etc. `object` is evaluated per row in tables/grids, so per-record show/hide works there.
1460
1471
 
1461
- 🚨 **`condition:` is NOT authorization — it only hides the button.** A hidden action still has a live route; anyone with the URL can trigger it. "Who may run this" belongs in the policy:
1472
+ 🚨 **`condition:` is NOT authorization: it only hides the button.** A hidden action still has a live route; anyone with the URL can trigger it. "Who may run this" belongs in the policy:
1462
1473
 
1463
1474
  ```ruby
1464
- # 🚫 WRONG — does not stop non-admins; the route is live.
1475
+ # 🚫 WRONG: does not stop non-admins; the route is live.
1465
1476
  action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
1466
- # ✅ RIGHT — authorization in the policy, enforced regardless of condition:
1477
+ # ✅ RIGHT: authorization in the policy, enforced regardless of condition:
1467
1478
  def wipe? = current_user.admin?
1468
1479
  ```
1469
1480
 
@@ -1475,18 +1486,18 @@ The two compose: an action's button shows only when the policy permits **and** t
1475
1486
  action :reposition, hidden: true
1476
1487
  ```
1477
1488
 
1478
- Renders in **no** toolbar, row dropdown, card, or bulk bar — regardless of visibility flags, policy, or `condition:`. Everything else stays live: the route, the policy predicate (`def reposition?`), and (for `interaction:` actions) the form + permitted-params machinery.
1489
+ Renders in **no** toolbar, row dropdown, card, or bulk bar, regardless of visibility flags, policy, or `condition:`. Everything else stays live: the route, the policy predicate (`def reposition?`), and (for `interaction:` actions) the form + permitted-params machinery.
1479
1490
 
1480
- Use it for an endpoint reached by **something other than a button** — a drag gesture, a custom Stimulus controller. The framework uses it for exactly that: `position_on` expands to `action :reposition, hidden: true`, and the kanban drop endpoint is declared the same way.
1491
+ Use it for an endpoint reached by **something other than a button**: a drag gesture, a custom Stimulus controller. The framework uses it for exactly that: `position_on` expands to `action :reposition, hidden: true`, and the kanban drop endpoint is declared the same way.
1481
1492
 
1482
1493
  | | `hidden: true` | `condition: -> { false }` |
1483
1494
  |---|---|---|
1484
1495
  | Decided | class-load, once | render time, per row/request |
1485
1496
  | Says | "never a button" | "a button, just not right now" |
1486
1497
 
1487
- 🚨 **`hidden:` is a display gate, NOT an authorization boundary** — same trap as `condition:`. The route is live; authorization belongs in the policy.
1498
+ 🚨 **`hidden:` is a display gate, NOT an authorization boundary**: same trap as `condition:`. The route is live; authorization belongs in the policy.
1488
1499
 
1489
- `Action#with(...)` — actions are frozen value objects; clone with overrides:
1500
+ `Action#with(...)`: actions are frozen value objects; clone with overrides:
1490
1501
 
1491
1502
  ```ruby
1492
1503
  def customize_actions
@@ -1534,7 +1545,7 @@ class PostDefinition < ResourceDefinition
1534
1545
  end
1535
1546
  ```
1536
1547
 
1537
- ⚠️ **An interaction is the button, not the operation.** It's a presentation object — it can only be built with a `view_context`, so anything reachable only through one is reachable only from a Plutonium page. Logic may *start* in `execute` (a one-off with a single caller is fine; don't pre-extract). The **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**, in domain language (`publish!`, `archive!`, `register!`). Not a service layer. Full rule + the validation split: [[plutonium-behavior]] › Part 3 › Where the logic goes.
1548
+ ⚠️ **An interaction is the button, not the operation.** It's a presentation object: it can only be built with a `view_context`, so anything reachable only through one is reachable only from a Plutonium page. Logic may *start* in `execute` (a one-off with a single caller is fine; don't pre-extract). The **second caller** (a job, an API controller, a rake task, the console) is the trigger to move it onto the **model**, in domain language (`publish!`, `archive!`, `register!`). Not a service layer. Full rule + the validation split: [[plutonium-behavior]] › Part 3 › Where the logic goes.
1538
1549
 
1539
1550
  ### Single-record interaction
1540
1551
 
@@ -1574,7 +1585,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
1574
1585
  validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
1575
1586
 
1576
1587
  def execute
1577
- # Company#invite! creates the row AND sends the mail — a seat-provisioning
1588
+ # Company#invite! creates the row AND sends the mail, a seat-provisioning
1578
1589
  # job needs both, and has no view_context to build an interaction with.
1579
1590
  resource.invite!(email: email, role: role, by: current_user)
1580
1591
  succeed(resource).with_message("Invitation sent to #{email}.")
@@ -1604,7 +1615,7 @@ end
1604
1615
  action :bulk_archive, interaction: BulkArchiveInteraction
1605
1616
  # bulk_action: true inferred from `attribute :resources`
1606
1617
 
1607
- # Policy — checked per record; fails the request if ANY record is unauthorized
1618
+ # Policy: checked per record; fails the request if ANY record is unauthorized
1608
1619
  class PostPolicy < ResourcePolicy
1609
1620
  def bulk_archive? = create?
1610
1621
  end
@@ -1678,13 +1689,13 @@ end
1678
1689
 
1679
1690
  ## Immediate vs Form
1680
1691
 
1681
- - **Immediate** — interaction has only `:resource` (or `:resources`) and no other inputs. Shows an auto-generated browser confirmation (`"#{label}?"`, e.g. `"Archive?"`) on click, then runs. Pass `confirmation: "Custom message"` to override, or `confirmation: false` to skip.
1682
- - **Form** — interaction declares extra `attribute`/`input` beyond `:resource`/`:resources`. Renders a modal form first; no auto-confirmation (the form itself is the confirmation step).
1692
+ - **Immediate**: interaction has only `:resource` (or `:resources`) and no other inputs. Shows an auto-generated browser confirmation (`"#{label}?"`, e.g. `"Archive?"`) on click, then runs. Pass `confirmation: "Custom message"` to override, or `confirmation: false` to skip.
1693
+ - **Form**: interaction declares extra `attribute`/`input` beyond `:resource`/`:resources`. Renders a modal form first; no auto-confirmation (the form itself is the confirmation step).
1683
1694
 
1684
1695
  ## CSV Export (built-in)
1685
1696
 
1686
1697
  Every resource has a streamed CSV export, **disabled by default**. It is not declared
1687
- with `action :export_csv` — it's a policy-gated capability with its own split button.
1698
+ with `action :export_csv`: it's a policy-gated capability with its own split button.
1688
1699
  The route (`GET /<resources>/export_csv`) is auto-mounted; the button appears on the
1689
1700
  index page once the policy permits it. Enable it by overriding one policy method:
1690
1701
 
@@ -1695,9 +1706,9 @@ end
1695
1706
  ```
1696
1707
 
1697
1708
  **Two exports** (split button in the index toolbar, after Filter):
1698
- - **Export** (primary) — the current view: selected scope + filters + search (the
1709
+ - **Export** (primary): the current view: selected scope + filters + search (the
1699
1710
  index's `?q`), all matching rows (not just the visible page). File: `posts_<date>.csv`.
1700
- - **Export all** (dropdown) — the entire authorized scope, ignoring scope/filters/
1711
+ - **Export all** (dropdown): the entire authorized scope, ignoring scope/filters/
1701
1712
  search (`?all=1`). File: `posts_all_<date>.csv`.
1702
1713
 
1703
1714
  Both stream via `find_each` (memory-safe on large tables; primary-key order, so the
@@ -1710,7 +1721,7 @@ file does not preserve the index sort).
1710
1721
  def permitted_attributes_for_export = [:title, :author, :total, :created_at]
1711
1722
  ```
1712
1723
 
1713
- - **Per-field output** — customize a cell's value and header in the definition with
1724
+ - **Per-field output**: customize a cell's value and header in the definition with
1714
1725
  the `export` DSL (parallels `display`/`column`):
1715
1726
 
1716
1727
  ```ruby
@@ -1723,7 +1734,7 @@ end
1723
1734
  - **Without** an `export` block a column is read off the record: scalars as-is,
1724
1735
  associations as their `display_name_of` label (e.g. `User #5`, not `#<User:…>`). A
1725
1736
  computed/virtual column with no real method **needs** an `export` block (a `label:`-only
1726
- `export` doesn't supply a value) — otherwise the cell renders `<<invalid column>>`.
1737
+ `export` doesn't supply a value); otherwise the cell renders `<<invalid column>>`.
1727
1738
  - **CSV/formula injection** is neutralized automatically (cells starting with `= + - @` or
1728
1739
  tab/CR get a leading `'`).
1729
1740
 
@@ -1734,7 +1745,7 @@ The button opens in a new tab (so the streamed download bypasses Turbo). Full re
1734
1745
 
1735
1746
  ## Related Skills
1736
1747
 
1737
- - [[plutonium-behavior]] — controllers, policies (`permitted_attributes_for_*`, action methods), interactions
1738
- - [[plutonium-tenancy]] — `associated_with`, `relation_scope`, nested resources
1739
- - [[plutonium-ui]] — custom Phlex pages, forms, displays, tables
1740
- - [[plutonium-testing]] — testing resources, definitions, policies, interactions
1748
+ - [[plutonium-behavior]]: controllers, policies (`permitted_attributes_for_*`, action methods), interactions
1749
+ - [[plutonium-tenancy]]: `associated_with`, `relation_scope`, nested resources
1750
+ - [[plutonium-ui]]: custom Phlex pages, forms, displays, tables
1751
+ - [[plutonium-testing]]: testing resources, definitions, policies, interactions