plutonium 0.65.0 → 0.67.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 (164) 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 +158 -141
  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 +161 -98
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +43 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +56 -3
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +32 -32
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/config/brakeman.ignore +1 -1
  23. data/config/locales/en/js.yml +4 -0
  24. data/config/locales/en/ui.yml +16 -0
  25. data/docs/.vitepress/sync-skills.mjs +6 -3
  26. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  27. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  28. data/docs/getting-started/installation.md +5 -5
  29. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  30. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  31. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  32. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  33. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  34. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  36. data/docs/getting-started/tutorial/index.md +1 -1
  37. data/docs/guides/adding-resources.md +10 -7
  38. data/docs/guides/authentication.md +25 -25
  39. data/docs/guides/authorization.md +24 -24
  40. data/docs/guides/creating-packages.md +17 -17
  41. data/docs/guides/custom-actions.md +32 -32
  42. data/docs/guides/customizing-ui.md +29 -26
  43. data/docs/guides/dashboards.md +1 -1
  44. data/docs/guides/index.md +3 -3
  45. data/docs/guides/kanban.md +55 -55
  46. data/docs/guides/multi-tenancy.md +35 -22
  47. data/docs/guides/nested-resources.md +21 -21
  48. data/docs/guides/performance.md +3 -3
  49. data/docs/guides/search-filtering.md +13 -13
  50. data/docs/guides/testing.md +16 -12
  51. data/docs/guides/theming.md +32 -17
  52. data/docs/guides/troubleshooting.md +2 -2
  53. data/docs/guides/user-invites.md +17 -17
  54. data/docs/guides/user-profile.md +51 -24
  55. data/docs/guides/wizards.md +55 -55
  56. data/docs/reference/app/generators.md +22 -22
  57. data/docs/reference/app/index.md +15 -18
  58. data/docs/reference/app/packages.md +8 -8
  59. data/docs/reference/app/portals.md +75 -29
  60. data/docs/reference/auth/accounts.md +15 -15
  61. data/docs/reference/auth/index.md +12 -12
  62. data/docs/reference/auth/profile.md +67 -29
  63. data/docs/reference/behavior/async-interactions.md +24 -24
  64. data/docs/reference/behavior/controllers.md +28 -28
  65. data/docs/reference/behavior/index.md +5 -5
  66. data/docs/reference/behavior/interactions.md +44 -44
  67. data/docs/reference/behavior/policies.md +48 -28
  68. data/docs/reference/configuration.md +6 -6
  69. data/docs/reference/dashboard/dsl.md +2 -2
  70. data/docs/reference/dashboard/index.md +1 -1
  71. data/docs/reference/generators/lite.md +7 -7
  72. data/docs/reference/i18n.md +23 -0
  73. data/docs/reference/index.md +1 -1
  74. data/docs/reference/kanban/authorization.md +9 -9
  75. data/docs/reference/kanban/dsl.md +32 -32
  76. data/docs/reference/kanban/index.md +1 -1
  77. data/docs/reference/kanban/positioning.md +17 -15
  78. data/docs/reference/resource/actions.md +51 -51
  79. data/docs/reference/resource/definition.md +85 -80
  80. data/docs/reference/resource/export.md +6 -6
  81. data/docs/reference/resource/index.md +16 -16
  82. data/docs/reference/resource/model.md +24 -24
  83. data/docs/reference/resource/positioning.md +78 -76
  84. data/docs/reference/resource/query.md +24 -14
  85. data/docs/reference/tenancy/entity-scoping.md +65 -35
  86. data/docs/reference/tenancy/index.md +11 -11
  87. data/docs/reference/tenancy/invites.md +20 -20
  88. data/docs/reference/tenancy/nested-resources.md +13 -13
  89. data/docs/reference/testing/index.md +116 -22
  90. data/docs/reference/ui/assets.md +57 -25
  91. data/docs/reference/ui/components.md +20 -20
  92. data/docs/reference/ui/displays.md +32 -15
  93. data/docs/reference/ui/forms.md +89 -39
  94. data/docs/reference/ui/index.md +17 -15
  95. data/docs/reference/ui/layouts.md +21 -21
  96. data/docs/reference/ui/pages.md +22 -22
  97. data/docs/reference/ui/tables.md +7 -7
  98. data/docs/reference/wizard/anchoring-resume.md +33 -32
  99. data/docs/reference/wizard/dsl.md +44 -44
  100. data/docs/reference/wizard/index.md +6 -6
  101. data/docs/reference/wizard/one-time.md +18 -18
  102. data/docs/reference/wizard/registration-launch.md +32 -32
  103. data/docs/reference/wizard/storage-config.md +23 -23
  104. data/gemfiles/rails_7.gemfile +1 -0
  105. data/gemfiles/rails_7.gemfile.lock +13 -3
  106. data/gemfiles/rails_8.0.gemfile +1 -0
  107. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  108. data/gemfiles/rails_8.1.gemfile +1 -0
  109. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  110. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  111. data/lib/plutonium/auth/rodauth.rb +7 -0
  112. data/lib/plutonium/core/controller.rb +0 -2
  113. data/lib/plutonium/helpers/content_helper.rb +0 -3
  114. data/lib/plutonium/query/filter.rb +2 -1
  115. data/lib/plutonium/query/filters/tags.rb +36 -0
  116. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  117. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  118. data/lib/plutonium/ui/display/base.rb +50 -2
  119. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  120. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  121. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  122. data/lib/plutonium/ui/display/components/code.rb +23 -0
  123. data/lib/plutonium/ui/display/components/color.rb +14 -6
  124. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  125. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  126. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  127. data/lib/plutonium/ui/display/components/list.rb +33 -0
  128. data/lib/plutonium/ui/display/components/number.rb +32 -0
  129. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  130. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  131. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  132. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  133. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  134. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  135. data/lib/plutonium/ui/display/theme.rb +24 -2
  136. data/lib/plutonium/ui/form/base.rb +30 -1
  137. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  138. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  139. data/lib/plutonium/ui/form/components/list.rb +46 -0
  140. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  141. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  142. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  143. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  144. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  145. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  146. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  147. data/lib/plutonium/ui/form/theme.rb +20 -4
  148. data/lib/plutonium/ui/grid/card.rb +13 -1
  149. data/lib/plutonium/ui/layout/base.rb +10 -1
  150. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  151. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  152. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  153. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  154. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  155. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  156. data/lib/plutonium/version.rb +1 -1
  157. data/package.json +1 -1
  158. data/src/css/components.css +53 -10
  159. data/src/css/flatpickr.css +91 -0
  160. data/src/js/controllers/easymde_controller.js +1 -0
  161. data/src/js/controllers/register_controllers.js +2 -0
  162. data/src/js/controllers/slim_select_controller.js +32 -3
  163. data/src/js/controllers/timeago_controller.js +40 -0
  164. metadata +25 -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
 
@@ -532,29 +543,33 @@ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) ar
532
543
 
533
544
  | Category | Types |
534
545
  |----------|-------|
535
- | Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
536
- | Rich Text | `:markdown` (EasyMDE) |
537
- | Numeric | `:number`, `:integer`, `:decimal`, `:range` |
538
- | Boolean | `:toggle` / `:switch` (switch — **default** for boolean columns), `:boolean` (plain checkbox) |
546
+ | Text | `:string`, `:text`, `:email`, `:url`, `:phone`, `:password` (alias `:secret`) |
547
+ | Rich Text | `:rich_text` (Action Text editor: Trix, or Lexxy once installed; **default** for `has_rich_text`), `:markdown` (EasyMDE) |
548
+ | Numeric | `:number`, `:integer`, `:decimal`, `:range`, `:rating` (star picker, `max:`) |
549
+ | Boolean | `:toggle` / `:switch` (switch, **default** for boolean columns), `:boolean` (plain checkbox) |
539
550
  | Date/Time | `:date`, `:time`, `:datetime` |
540
- | Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
541
- | Files | `:file`, `:uppy`, `:attachment` |
551
+ | Selection | `:select`, `:slim_select`, `:collection_radio_buttons`, `:collection_checkboxes` |
552
+ | Lists | `:list` (chip input for array values; `suggestions:`, `addable:`, `limit:`), `:tags` (no_fly_list `has_tags` contexts, **auto-detected**) |
553
+ | Files | `:file`, `:uppy`, `:attachment` (Active Storage), `:binary` (bytes in a `binary` column) |
542
554
  | Associations | `:association`, `:secure_association`, `:belongs_to`, `:has_many`, `:has_one` |
543
- | Special | `:hidden`, `:color`, `:phone` |
555
+ | Special | `:hidden`, `:color`, `:json`, `:key_value` |
544
556
 
545
557
  ### Display Types (show / index)
546
558
 
547
- `:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:badge`, `:currency`, `:color`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`
559
+ `:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:badge`, `:currency`, `:color`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`, `:key_value`, `:binary`, `:list`, `:tags`, `:rich_text`, `:relative_time`, `:code`, `:duration`, `:file_size`, `:progress`, `:rating`
548
560
 
549
561
  #### Auto-inferred display formatting
550
562
 
551
- These render automatically — declare an `as:` only to override or pass options:
563
+ These render automatically: declare an `as:` only to override or pass options:
552
564
 
553
565
  | Column | Renders as | Notes |
554
566
  |--------|-----------|-------|
555
567
  | `boolean` | Yes/No pill (`:boolean`) | green "Yes" / neutral "No". Override labels: `true_label:`, `false_label:` |
556
568
  | `enum` | colored status badge (`:badge`) | known statuses (active, pending, failed…) auto-colored; unknown values get a stable decorative color |
557
- | `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals, **no symbol** unless you add `unit:` |
569
+ | `binary` | size (`:binary`) | `Binary data (12 KB)`, never the bytes |
570
+ | `has_rich_text` | HTML (`:rich_text`) | rendered and sanitized by Action Text |
571
+ | no_fly_list `has_tags` | chips (`:tags`) | declare by context name (`:labels`), not `labels_list` |
572
+ | `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals; symbol from `unit:` (display or `has_cents`), else `config.default_currency_unit` / i18n. `unit: false` drops it |
558
573
 
559
574
  ```ruby
560
575
  display :status, as: :badge, colors: {archived: :neutral, vip: :accent} # override per-value color
@@ -592,7 +607,7 @@ input :title,
592
607
  input :category, as: :select, choices: %w[Tech Business Lifestyle]
593
608
  input :status, as: :select, choices: Post.statuses.keys
594
609
 
595
- # Dynamic — must use a block
610
+ # Dynamic: must use a block
596
611
  input :author do |f|
597
612
  f.select_tag choices: User.active.pluck(:name, :id)
598
613
  end
@@ -624,24 +639,24 @@ A surface's own `condition:` wins; otherwise the surface falls back to the `fiel
624
639
 
625
640
  ## Options That Vary Per Render
626
641
 
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**:
642
+ 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
643
 
629
644
  ```ruby
630
645
  input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
631
646
  input :notes, placeholder: -> { "Updated #{Time.current.year}" }
632
647
  ```
633
648
 
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.
649
+ - `-> { … }` 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).
650
+ - `->(form) { … }` gets the form: `object` (the record), `params`, view helpers.
636
651
 
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]].
652
+ 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
653
 
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:
654
+ **`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
655
 
641
656
  ```ruby
642
657
  input :notes, condition: -> { object.published? } # the form
643
658
  display :audit_log, condition: -> { current_user.admin? } # the display component
644
- step :billing, condition: -> { data.plan.tier == "pro" } # the wizard — no form exists yet
659
+ step :billing, condition: -> { data.plan.tier == "pro" } # the wizard, no form exists yet
645
660
  ```
646
661
 
647
662
  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 +704,7 @@ class QuestionDefinition < ResourceDefinition
689
704
  choices: %w[text choice scale],
690
705
  pre_submit: true
691
706
 
692
- # No `as:` — types are auto-detected from the model. We only declare to add `condition:`.
707
+ # No `as:`: types are auto-detected from the model. We only declare to add `condition:`.
693
708
  input :max_length, condition: -> { object.question_type == "text" }
694
709
  input :choices, condition: -> { object.question_type == "choice" }
695
710
  input :min_value, condition: -> { object.question_type == "scale" }
@@ -716,7 +731,7 @@ Tips:
716
731
 
717
732
  ## Custom Rendering
718
733
 
719
- **Display block — return any component:**
734
+ **Display block: return any component:**
720
735
 
721
736
  ```ruby
722
737
  display :status do |field|
@@ -724,7 +739,7 @@ display :status do |field|
724
739
  end
725
740
  ```
726
741
 
727
- **Input block — must use form builder methods:**
742
+ **Input block: must use form builder methods:**
728
743
 
729
744
  ```ruby
730
745
  input :birth_date do |f|
@@ -762,14 +777,14 @@ end
762
777
 
763
778
  See [[plutonium-ui]] for writing custom Phlex components.
764
779
 
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`:
780
+ **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
781
 
767
782
  ```ruby
768
783
  input :color_picker, as: ColorPickerComponent
769
784
  display :chart, as: ChartComponent
770
785
  ```
771
786
 
772
- 🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate — it raises `ArgumentError`. Build it in a block instead:
787
+ 🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate; it raises `ArgumentError`. Build it in a block instead:
773
788
 
774
789
  ```ruby
775
790
  display :card do |field|
@@ -783,10 +798,10 @@ end
783
798
  column :title, align: :start # :start (default), :center, :end
784
799
  column :amount, align: :end
785
800
 
786
- # formatter — receives just the value
801
+ # formatter: receives just the value
787
802
  column :price, formatter: ->(v) { "$%.2f" % v if v }
788
803
 
789
- # block — receives the full record
804
+ # block: receives the full record
790
805
  column :full_name do |record|
791
806
  "#{record.first_name} #{record.last_name}"
792
807
  end
@@ -841,7 +856,7 @@ end
841
856
  |--------|-------------|
842
857
  | `limit` | Max records (auto-detected from model, default 10) |
843
858
  | `allow_destroy` | Show delete checkbox (auto-detected) |
844
- | `update_only` | Hide "Add" button — only edit existing |
859
+ | `update_only` | Hide "Add" button, only edit existing |
845
860
  | `description` | Help text above section |
846
861
  | `condition` | Proc to show/hide |
847
862
  | `using` | Another Definition class |
@@ -857,7 +872,7 @@ end
857
872
 
858
873
  ## Structured Inputs
859
874
 
860
- `structured_input` collects a **classless** group of fields — a single hash, or
875
+ `structured_input` collects a **classless** group of fields: a single hash, or
861
876
  (with `repeat:`) an array of hashes. No association or model class is involved.
862
877
  On resources the value is stored in a **JSON/jsonb column**; use it when you
863
878
  want structured data in a JSON column rather than a real association (which is
@@ -901,14 +916,14 @@ blank rows are dropped, `_destroy` stripped).
901
916
 
902
917
  ### Gotchas
903
918
 
904
- - The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed — the value assigns directly.
919
+ - The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed; the value assigns directly.
905
920
  - **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**.
921
+ - `repeat: 1` is "array, max one row", **not** the single form. Presence of `repeat:` always means an array.
922
+ - Rows are positional plain hashes: **no ids, no per-row class, no type coercion**.
908
923
  - **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
924
  - **`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`.
925
+ - 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.
926
+ - Same DSL works on **interactions** (see [[plutonium-behavior]] › Interactions); there it backs an ActiveModel attribute reaching `execute`.
912
927
 
913
928
  ## File Uploads
914
929
 
@@ -925,9 +940,9 @@ input :documents, as: :uppy,
925
940
 
926
941
  Inside `condition` procs and block-form `input`/`display`:
927
942
 
928
- - `object` — the record
943
+ - `object`: the record
929
944
  - `current_user`
930
- - `current_parent` — for nested resources
945
+ - `current_parent`: for nested resources
931
946
  - `request`, `params`
932
947
  - All helper methods
933
948
 
@@ -964,7 +979,7 @@ class PostDefinition < ResourceDefinition
964
979
  breadcrumbs true
965
980
  show_page_breadcrumbs false
966
981
 
967
- # Custom page classes — inherit from the parent's nested class
982
+ # Custom page classes: inherit from the parent's nested class
968
983
  class IndexPage < IndexPage
969
984
  def view_template(&block)
970
985
  div(class: "custom-header") { h1 { "Custom" } }
@@ -988,7 +1003,7 @@ end
988
1003
 
989
1004
  ## Form Layout (`form_layout`)
990
1005
 
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`.
1006
+ 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
1007
 
993
1008
  ```ruby
994
1009
  class PostDefinition < ResourceDefinition
@@ -1002,18 +1017,18 @@ class PostDefinition < ResourceDefinition
1002
1017
  end
1003
1018
  ```
1004
1019
 
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.
1020
+ - **Layout references field KEYS only**: all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
1021
+ - **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
1022
  - ⚠️ **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`.
1023
+ - **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`.
1024
+ - **🚨 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.
1025
+ - **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
1026
 
1012
1027
  Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
1013
1028
 
1014
1029
  ## Display Layout (`display_layout`)
1015
1030
 
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.
1031
+ 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
1032
 
1018
1033
  ```ruby
1019
1034
  class PostDefinition < ResourceDefinition
@@ -1025,14 +1040,14 @@ class PostDefinition < ResourceDefinition
1025
1040
  end
1026
1041
  ```
1027
1042
 
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.
1043
+ - **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.
1044
+ - **🚨 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.
1045
+ - **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`.
1046
+ - **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
1047
 
1033
1048
  ## Page Width (`page_width`)
1034
1049
 
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.
1050
+ 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
1051
 
1037
1052
  ```ruby
1038
1053
  Plutonium.configure { |c| c.default_page_width = :md } # global default (:md)
@@ -1045,12 +1060,12 @@ end
1045
1060
  ```
1046
1061
 
1047
1062
  - **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).
1063
+ - **🚨 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
1064
  - **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
1050
1065
  - **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`).
1066
+ - **Modals are unaffected**: the dialog sets its own width (`modal_size`).
1052
1067
  - **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.
1068
+ - **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
1069
 
1055
1070
  ## Metadata Panel (show page)
1056
1071
 
@@ -1060,12 +1075,12 @@ Declares fields rendered in the show page's right-side aside as label/value rows
1060
1075
  metadata :author, :state, :created_at, :updated_at
1061
1076
  ```
1062
1077
 
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.
1078
+ - **Opt-in**: no call → show page is full-width with no aside.
1079
+ - **Policy-aware**: fields the user can't see disappear; panel auto-hides if nothing's permitted.
1080
+ - **Deduplicated**: listed fields are removed from the main details card (and from any `display_layout` section).
1081
+ - **Responsive**: side-by-side at `lg+`, stacked below.
1067
1082
  - **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.
1083
+ - **A kanban card's modal drops metadata entirely**: the fields are hidden, not folded into the main card.
1069
1084
 
1070
1085
  Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
1071
1086
 
@@ -1075,7 +1090,7 @@ Resources can offer both Table and Grid views; user choice persists per-resource
1075
1090
 
1076
1091
  ```ruby
1077
1092
  class UserDefinition < ResourceDefinition
1078
- # No `index_views :table, :grid` needed — `grid_fields` auto-enables :grid alongside the default :table.
1093
+ # No `index_views :table, :grid` needed, `grid_fields` auto-enables :grid alongside the default :table.
1079
1094
  grid_fields(
1080
1095
  image: :avatar, # ActiveStorage, Shrine, or URL
1081
1096
  header: :name, # falls back to to_label
@@ -1085,7 +1100,7 @@ class UserDefinition < ResourceDefinition
1085
1100
  footer: :last_seen_at # falls back to :created_at; `false` to omit
1086
1101
  )
1087
1102
 
1088
- default_index_view :grid # optional — initial view when no cookie
1103
+ default_index_view :grid # optional: initial view when no cookie
1089
1104
  grid_layout :media # :compact (default) or :media
1090
1105
  grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
1091
1106
  end
@@ -1107,23 +1122,23 @@ All grid slots are optional; slots pointing at unpermitted fields collapse silen
1107
1122
 
1108
1123
  ## Drag-to-Reorder (`positioned_on` + `position_on`)
1109
1124
 
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:
1125
+ 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
1126
 
1112
1127
  ```ruby
1113
- # Migration — t.position emits decimal(16,8), tuned for fractional ordering.
1128
+ # Migration: t.position emits decimal(16,8), tuned for fractional ordering.
1114
1129
  create_table :tasks do |t|
1115
1130
  t.string :status, null: false, default: "todo"
1116
1131
  t.position
1117
1132
  t.index [:status, :position] # match the scope attribute
1118
1133
  end
1119
1134
 
1120
- # Model — storage
1135
+ # Model: storage
1121
1136
  class Task < ApplicationRecord
1122
1137
  include Plutonium::Positioning::Model # NOT Plutonium::Positioning
1123
1138
  positioned_on :position, scope: :status # scope: nil = one global ordering
1124
1139
  end
1125
1140
 
1126
- # Definition — "this UI can be reordered". Never restates the column or the scope.
1141
+ # Definition: "this UI can be reordered". Never restates the column or the scope.
1127
1142
  class TaskDefinition < ResourceDefinition
1128
1143
  position_on
1129
1144
  end
@@ -1132,45 +1147,45 @@ end
1132
1147
  Task.backfill_positions!(order: :created_at)
1133
1148
  ```
1134
1149
 
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.
1150
+ - **`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`.
1151
+ - **`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
1152
 
1138
1153
  ### `position_on` forms and modes
1139
1154
 
1140
1155
  | Form | Mode | Notes |
1141
1156
  |---|---|---|
1142
- | `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this** — it cannot disagree with the model. |
1157
+ | `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this**: it cannot disagree with the model. |
1143
1158
  | `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 |
1159
+ | `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch: another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
1160
+ | `position_on false` | C (disabled) | No ordering, no route, the endpoint 404s |
1146
1161
 
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.
1162
+ **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
1163
 
1149
- ### Mode B — what the framework stops doing
1164
+ ### Mode B: what the framework stops doing
1150
1165
 
1151
1166
  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
1167
 
1153
1168
  Because the write is opaque, three Mode A behaviours are **not** provided:
1154
1169
 
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.
1170
+ - **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.
1171
+ - **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.
1172
+ - **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
1173
 
1159
1174
  ### Migrating off `acts_as_list` to Mode A
1160
1175
 
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`.
1176
+ 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`.
1177
+ 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).
1178
+ 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
1179
  4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.
1165
1180
 
1166
1181
  ### Staying on `acts_as_list` (the harder road)
1167
1182
 
1168
1183
  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
1184
 
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.
1185
+ 🚨 **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
1186
 
1172
1187
  ```ruby
1173
- # Keeping acts_as_list. NOTE scope: [:status] — a bare Symbol scope is run
1188
+ # Keeping acts_as_list. NOTE scope: [:status], a bare Symbol scope is run
1174
1189
  # through acts_as_list's `idify`, which turns :status into :status_id and makes
1175
1190
  # every create raise NoMethodError.
1176
1191
  class Task < ApplicationRecord
@@ -1186,7 +1201,7 @@ class TaskDefinition < ResourceDefinition
1186
1201
  # Removing the record shifts prev up one when the record was above it.
1187
1202
  (record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
1188
1203
  elsif move.next
1189
- # Blank prev means "nothing above me ON MY SCREEN" — rows may still sit
1204
+ # Blank prev means "nothing above me ON MY SCREEN"; rows may still sit
1190
1205
  # above off-page or behind a filter, so anchor off next rather than 1.
1191
1206
  (record.position < move.next.position) ? move.next.position - 1 : move.next.position
1192
1207
  else
@@ -1198,7 +1213,7 @@ class TaskDefinition < ResourceDefinition
1198
1213
  end
1199
1214
  ```
1200
1215
 
1201
- `insert_at` calls `save`, not `save!` — a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
1216
+ `insert_at` calls `save`, not `save!`; a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
1202
1217
 
1203
1218
  ### 🚨 `position_on` expands to three things
1204
1219
 
@@ -1208,21 +1223,21 @@ default_sort <attr>, :asc # ⚠ ONLY when default_sort is still the frame
1208
1223
  action :reposition, hidden: true # route + policy predicate, no button
1209
1224
  ```
1210
1225
 
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.
1226
+ **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
1227
 
1213
1228
  ### Behavior notes
1214
1229
 
1215
1230
  - **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
1231
  - **`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
1232
  - **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.
1233
+ - **`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
1234
  - Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
1220
1235
 
1221
1236
  Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
1222
1237
 
1223
1238
  ---
1224
1239
 
1225
- # Part 4 — Query: Search, Filters, Scopes, Sorting
1240
+ # Part 4: Query: Search, Filters, Scopes, Sorting
1226
1241
 
1227
1242
  ```ruby
1228
1243
  class PostDefinition < ResourceDefinition
@@ -1266,6 +1281,7 @@ end
1266
1281
  | Date Range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
1267
1282
  | Select | `:select` | `value` | `choices:`, `multiple:` |
1268
1283
  | Association | `:association` | `value` | `class_name:`, `multiple:` |
1284
+ | Tags | `:tags` | `value` | `match:` (`:any` default, `:all`); no_fly_list contexts, options limited to the policy scope |
1269
1285
 
1270
1286
  **Text predicates:** `:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
1271
1287
  **Date predicates:** `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`
@@ -1280,6 +1296,7 @@ filter :category, with: :select, choices: -> { Category.pluck(:name) }
1280
1296
  filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
1281
1297
  filter :category, with: :association
1282
1298
  filter :author, with: :association, class_name: User
1299
+ filter :labels, with: :tags # no_fly_list context
1283
1300
  ```
1284
1301
 
1285
1302
  **Custom filter class:**
@@ -1317,7 +1334,7 @@ default_scope :published # applied on initial load; "All" button clears it
1317
1334
 
1318
1335
  ### Conditional scopes (`condition:`)
1319
1336
 
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.
1337
+ 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
1338
 
1322
1339
  ```ruby
1323
1340
  scope :admin_only, condition: -> { current_user.admin? }
@@ -1325,7 +1342,7 @@ scope :beta_feature, condition: -> { params[:beta] == "1" }
1325
1342
  scope :never_shown, condition: -> { false } # hides button but URL still works
1326
1343
  ```
1327
1344
 
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).
1345
+ 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
1346
 
1330
1347
  🚨 **`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
1348
 
@@ -1353,7 +1370,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1353
1370
 
1354
1371
  ---
1355
1372
 
1356
- # Part 5 — Actions: Custom and Bulk
1373
+ # Part 5: Actions: Custom and Bulk
1357
1374
 
1358
1375
  ## Action Types
1359
1376
 
@@ -1365,7 +1382,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1365
1382
  | `bulk_action: true` | Selected records | Bulk operations |
1366
1383
  | `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |
1367
1384
 
1368
- 🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes — don't declare them manually:**
1385
+ 🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes, so don't declare them manually:**
1369
1386
 
1370
1387
  | Interaction declares | Inferred flags |
1371
1388
  |---|---|
@@ -1373,7 +1390,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1373
1390
  | `attribute :resources` (plural) | `bulk_action` |
1374
1391
  | neither | `resource_action` |
1375
1392
 
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:
1393
+ 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
1394
 
1378
1395
  ```ruby
1379
1396
  # :resource interaction defaults to record_action + collection_record_action.
@@ -1402,11 +1419,11 @@ action :name,
1402
1419
  collection_record_action: true,
1403
1420
  bulk_action: true,
1404
1421
 
1405
- # Conditional visibility — display-only toggle, NOT authorization (see below).
1422
+ # Conditional visibility: display-only toggle, NOT authorization (see below).
1406
1423
  # `-> { false }` keeps the route live but hides the button (e.g. API-only).
1407
1424
  condition: -> { params[:beta] == "1" },
1408
1425
 
1409
- # Never renders anywhere — route + policy stay live. For endpoints reached by
1426
+ # Never renders anywhere: route + policy stay live. For endpoints reached by
1410
1427
  # a gesture rather than a button (see Hidden Actions below). NOT authorization.
1411
1428
  hidden: true,
1412
1429
 
@@ -1418,17 +1435,17 @@ action :name,
1418
1435
  confirmation: "Are you sure?",
1419
1436
  turbo_frame: "_top",
1420
1437
  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
1438
+ modal: :slideover, # :slideover / :centered, overrides definition's modal mode
1439
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
1423
1440
 
1424
- # HTML attributes — deep-merged over the framework's, author wins on every key
1441
+ # HTML attributes: deep-merged over the framework's, author wins on every key
1425
1442
  link: {target: "_blank", rel: "noopener"}, # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
1426
1443
  button: {data: {analytics: "x"}} # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
1427
1444
  ```
1428
1445
 
1429
1446
  ### HTML Attributes (`link:` / `button:`)
1430
1447
 
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`.
1448
+ 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
1449
 
1433
1450
  ```ruby
1434
1451
  action :documentation,
@@ -1441,29 +1458,29 @@ Both bags round-trip through `with(...)`: `defined_actions[:edit].with(link: {ta
1441
1458
 
1442
1459
  ### Conditional Actions (`condition:`)
1443
1460
 
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.
1461
+ 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
1462
 
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:
1463
+ 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
1464
 
1448
1465
  ```ruby
1449
1466
  # Defined and callable (API / programmatic), but no button anywhere:
1450
1467
  action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
1451
1468
 
1452
- # Per-record display state — object is the row/shown record:
1469
+ # Per-record display state: object is the row/shown record:
1453
1470
  action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
1454
1471
 
1455
1472
  # View/request-level toggle (feature flag, beta mode):
1456
1473
  action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
1457
1474
  ```
1458
1475
 
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.
1476
+ 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
1477
 
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:
1478
+ 🚨 **`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
1479
 
1463
1480
  ```ruby
1464
- # 🚫 WRONG — does not stop non-admins; the route is live.
1481
+ # 🚫 WRONG: does not stop non-admins; the route is live.
1465
1482
  action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
1466
- # ✅ RIGHT — authorization in the policy, enforced regardless of condition:
1483
+ # ✅ RIGHT: authorization in the policy, enforced regardless of condition:
1467
1484
  def wipe? = current_user.admin?
1468
1485
  ```
1469
1486
 
@@ -1475,18 +1492,18 @@ The two compose: an action's button shows only when the policy permits **and** t
1475
1492
  action :reposition, hidden: true
1476
1493
  ```
1477
1494
 
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.
1495
+ 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
1496
 
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.
1497
+ 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
1498
 
1482
1499
  | | `hidden: true` | `condition: -> { false }` |
1483
1500
  |---|---|---|
1484
1501
  | Decided | class-load, once | render time, per row/request |
1485
1502
  | Says | "never a button" | "a button, just not right now" |
1486
1503
 
1487
- 🚨 **`hidden:` is a display gate, NOT an authorization boundary** — same trap as `condition:`. The route is live; authorization belongs in the policy.
1504
+ 🚨 **`hidden:` is a display gate, NOT an authorization boundary**: same trap as `condition:`. The route is live; authorization belongs in the policy.
1488
1505
 
1489
- `Action#with(...)` — actions are frozen value objects; clone with overrides:
1506
+ `Action#with(...)`: actions are frozen value objects; clone with overrides:
1490
1507
 
1491
1508
  ```ruby
1492
1509
  def customize_actions
@@ -1534,7 +1551,7 @@ class PostDefinition < ResourceDefinition
1534
1551
  end
1535
1552
  ```
1536
1553
 
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.
1554
+ ⚠️ **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
1555
 
1539
1556
  ### Single-record interaction
1540
1557
 
@@ -1574,7 +1591,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
1574
1591
  validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
1575
1592
 
1576
1593
  def execute
1577
- # Company#invite! creates the row AND sends the mail — a seat-provisioning
1594
+ # Company#invite! creates the row AND sends the mail, a seat-provisioning
1578
1595
  # job needs both, and has no view_context to build an interaction with.
1579
1596
  resource.invite!(email: email, role: role, by: current_user)
1580
1597
  succeed(resource).with_message("Invitation sent to #{email}.")
@@ -1604,7 +1621,7 @@ end
1604
1621
  action :bulk_archive, interaction: BulkArchiveInteraction
1605
1622
  # bulk_action: true inferred from `attribute :resources`
1606
1623
 
1607
- # Policy — checked per record; fails the request if ANY record is unauthorized
1624
+ # Policy: checked per record; fails the request if ANY record is unauthorized
1608
1625
  class PostPolicy < ResourcePolicy
1609
1626
  def bulk_archive? = create?
1610
1627
  end
@@ -1678,13 +1695,13 @@ end
1678
1695
 
1679
1696
  ## Immediate vs Form
1680
1697
 
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).
1698
+ - **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.
1699
+ - **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
1700
 
1684
1701
  ## CSV Export (built-in)
1685
1702
 
1686
1703
  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.
1704
+ with `action :export_csv`: it's a policy-gated capability with its own split button.
1688
1705
  The route (`GET /<resources>/export_csv`) is auto-mounted; the button appears on the
1689
1706
  index page once the policy permits it. Enable it by overriding one policy method:
1690
1707
 
@@ -1695,9 +1712,9 @@ end
1695
1712
  ```
1696
1713
 
1697
1714
  **Two exports** (split button in the index toolbar, after Filter):
1698
- - **Export** (primary) — the current view: selected scope + filters + search (the
1715
+ - **Export** (primary): the current view: selected scope + filters + search (the
1699
1716
  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/
1717
+ - **Export all** (dropdown): the entire authorized scope, ignoring scope/filters/
1701
1718
  search (`?all=1`). File: `posts_all_<date>.csv`.
1702
1719
 
1703
1720
  Both stream via `find_each` (memory-safe on large tables; primary-key order, so the
@@ -1710,7 +1727,7 @@ file does not preserve the index sort).
1710
1727
  def permitted_attributes_for_export = [:title, :author, :total, :created_at]
1711
1728
  ```
1712
1729
 
1713
- - **Per-field output** — customize a cell's value and header in the definition with
1730
+ - **Per-field output**: customize a cell's value and header in the definition with
1714
1731
  the `export` DSL (parallels `display`/`column`):
1715
1732
 
1716
1733
  ```ruby
@@ -1723,7 +1740,7 @@ end
1723
1740
  - **Without** an `export` block a column is read off the record: scalars as-is,
1724
1741
  associations as their `display_name_of` label (e.g. `User #5`, not `#<User:…>`). A
1725
1742
  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>>`.
1743
+ `export` doesn't supply a value); otherwise the cell renders `<<invalid column>>`.
1727
1744
  - **CSV/formula injection** is neutralized automatically (cells starting with `= + - @` or
1728
1745
  tab/CR get a leading `'`).
1729
1746
 
@@ -1734,7 +1751,7 @@ The button opens in a new tab (so the streamed download bypasses Turbo). Full re
1734
1751
 
1735
1752
  ## Related Skills
1736
1753
 
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
1754
+ - [[plutonium-behavior]]: controllers, policies (`permitted_attributes_for_*`, action methods), interactions
1755
+ - [[plutonium-tenancy]]: `associated_with`, `relation_scope`, nested resources
1756
+ - [[plutonium-ui]]: custom Phlex pages, forms, displays, tables
1757
+ - [[plutonium-testing]]: testing resources, definitions, policies, interactions