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
  # Definition
2
2
 
3
- Definitions configure **how** a resource is rendered and interacted with — which fields appear, how they render, what page chrome looks like. Auto-detection from the model handles the defaults; declare only what you're overriding.
3
+ Definitions configure **how** a resource is rendered and interacted with: which fields appear, how they render, what page chrome looks like. Auto-detection from the model handles the defaults; declare only what you're overriding.
4
4
 
5
5
  For search/filters/scopes/sorting see [Query](./query). For custom actions see [Actions](./actions).
6
6
 
@@ -10,7 +10,7 @@ For search/filters/scopes/sorting see [Query](./query). For custom actions see [
10
10
  - **Use `condition:` for UI state, the policy for authorization.** `condition: -> { object.published? }` is fine. "Only admins see this field" belongs in `permitted_attributes_for_*`.
11
11
  - **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (see [Behavior › Policy](/reference/behavior/policies)).
12
12
  - **`has_cents` fields use the virtual name** (`field :price`), never `:price_cents`.
13
- - **Nested inputs need `accepts_nested_attributes_for` AND `inverse_of:` on the child's `belongs_to`** — without `inverse_of:`, validation fails with "Parent must exist" because the parent isn't saved yet.
13
+ - **Nested inputs need `accepts_nested_attributes_for` AND `inverse_of:` on the child's `belongs_to`**, without `inverse_of:`, validation fails with "Parent must exist" because the parent isn't saved yet.
14
14
 
15
15
  ## File location
16
16
 
@@ -102,7 +102,7 @@ end
102
102
  | Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
103
103
  | Rich text | `:markdown` (EasyMDE editor) |
104
104
  | Numeric | `:number`, `:integer`, `:decimal`, `:range` |
105
- | Boolean | `:toggle` / `:switch` (switch — **default** for boolean columns), `:boolean` (plain checkbox) |
105
+ | Boolean | `:toggle` / `:switch` (switch: **default** for boolean columns), `:boolean` (plain checkbox) |
106
106
  | Date/Time | `:date`, `:time`, `:datetime` |
107
107
  | Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
108
108
  | Files | `:file`, `:uppy`, `:attachment` |
@@ -115,7 +115,7 @@ end
115
115
 
116
116
  #### Auto-inferred display formatting
117
117
 
118
- These render automatically — declare an `as:` only to override or pass options:
118
+ These render automatically; declare an `as:` only to override or pass options:
119
119
 
120
120
  | Column | Renders as | Notes |
121
121
  |---|---|---|
@@ -132,11 +132,11 @@ display :active, as: :boolean, true_label: "Live", false_label: "Off"
132
132
  **Currency symbol.** The `unit:` can be set on the model's `has_cents` declaration
133
133
  (`has_cents :price_cents, unit: "£"`, or `unit: :currency_symbol` to read a method
134
134
  off the record for per-row currencies). That model-level unit is used everywhere the
135
- value renders as currency — the show page, tables, **and grid/kanban cards**. A
135
+ value renders as currency: the show page, tables, **and grid/kanban cards**. A
136
136
  per-display `unit:` overrides it for that one display; `unit: false` explicitly
137
137
  renders no symbol. When neither is set, currency falls back to
138
138
  `Plutonium.configuration.default_currency_unit` (default: the i18n
139
- `number.currency.format.unit` if the locale defines it — `$` in `en` — else no symbol).
139
+ `number.currency.format.unit` if the locale defines it, `$` in `en`, else no symbol).
140
140
 
141
141
  ## Field options
142
142
 
@@ -206,22 +206,22 @@ Keep a `field` condition to context that exists on every surface (`Rails.env`, a
206
206
  feature flag, `current_user`) and put record checks on `input`, `display` or `column`.
207
207
 
208
208
  ::: warning UI state, not authorization
209
- `condition:` is for UI logic ("show this when published"). For "who can see this", use the policy's `permitted_attributes_for_*` — see [Behavior › Policy](/reference/behavior/policies).
209
+ `condition:` is for UI logic ("show this when published"). For "who can see this", use the policy's `permitted_attributes_for_*`; see [Behavior › Policy](/reference/behavior/policies).
210
210
  :::
211
211
 
212
212
  ## Options that vary per render
213
213
 
214
- Any option may be a **proc**, resolved on every render rather than frozen when the class loads. This holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input` and nested inputs. Arity says **whether you want the form**:
214
+ Any option may be a **proc**, resolved on every render rather than frozen when the class loads. This holds across the whole form DSL: `field`, `input`, `section`/`ungrouped`, `structured_input` and nested inputs. Arity says **whether you want the form**:
215
215
 
216
216
  ```ruby
217
217
  input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
218
218
  input :notes, placeholder: -> { "Updated #{Time.current.year}" }
219
219
  ```
220
220
 
221
- - **`-> { … }`** is called as-is, keeping whatever it closed over — it means what it reads like where you wrote it. Nothing rebinds `self`. That is what lets an option declared inside an interaction's `customize_inputs` reach the interaction, private helpers included: `choices: -> { reviewer_choices }`.
221
+ - **`-> { … }`** is called as-is, keeping whatever it closed over: it means what it reads like where you wrote it. Nothing rebinds `self`. That is what lets an option declared inside an interaction's `customize_inputs` reach the interaction, private helpers included: `choices: -> { reviewer_choices }`.
222
222
  - **`->(form) { … }`** is handed the form, so `object` (the record being edited), `params` and view helpers are reachable. Use it whenever the value depends on what is being rendered.
223
223
 
224
- The rule holds on wizard steps too — but there a zero-argument proc closes over an internal field recorder, so options must take the form and read the run off it: `->(form) { form.wizard.anchor.tiers }`. See [Wizard DSL › Runtime input options](/reference/wizard/dsl#runtime-input-options).
224
+ The rule holds on wizard steps too, but there a zero-argument proc closes over an internal field recorder, so options must take the form and read the run off it: `->(form) { form.wizard.anchor.tiers }`. See [Wizard DSL › Runtime input options](/reference/wizard/dsl#runtime-input-options).
225
225
 
226
226
  ### `condition:` is not an option
227
227
 
@@ -229,10 +229,10 @@ The rule holds on wizard steps too — but there a zero-argument proc closes ove
229
229
 
230
230
  | | asks | so it | receiver |
231
231
  |---|---|---|---|
232
- | an **option** (`choices:`, `label:`, `collapsed:`, …) | "what value should this have?" | may or may not care about the render — so it means what it reads like where you wrote it, and takes `form` when it does care | its own closure, or the form |
233
- | **`condition:`** | "should this render *here, now*?" | is a question about the render context by definition — there is no useful reading of it that ignores that context | always the thing doing the rendering |
232
+ | an **option** (`choices:`, `label:`, `collapsed:`, …) | "what value should this have?" | may or may not care about the render, so it means what it reads like where you wrote it, and takes `form` when it does care | its own closure, or the form |
233
+ | **`condition:`** | "should this render *here, now*?" | is a question about the render context by definition, there is no useful reading of it that ignores that context | always the thing doing the rendering |
234
234
 
235
- So `condition:` always runs **against** its context and reads it with no argument — and "its context" is whatever is rendering: the form for a field, section or nested input; the component for a `column` or `display`; the **wizard** for a step's `condition:` (evaluated in the runner to decide which steps exist, before any form is built); a condition context for an action or scope.
235
+ So `condition:` always runs **against** its context and reads it with no argument, and "its context" is whatever is rendering: the form for a field, section or nested input; the component for a `column` or `display`; the **wizard** for a step's `condition:` (evaluated in the runner to decide which steps exist, before any form is built); a condition context for an action or scope.
236
236
 
237
237
  ```ruby
238
238
  input :notes, condition: -> { object.published? } # form
@@ -253,7 +253,7 @@ class QuestionDefinition < ResourceDefinition
253
253
  choices: %w[text choice scale],
254
254
  pre_submit: true
255
255
 
256
- # Dependents — no `as:` needed when the model column type matches
256
+ # Dependents: no `as:` needed when the model column type matches
257
257
  input :max_length, condition: -> { object.question_type == "text" }
258
258
  input :choices, condition: -> { object.question_type == "choice" }
259
259
  input :min_value, condition: -> { object.question_type == "scale" }
@@ -341,7 +341,7 @@ See [UI › Components](/reference/ui/components) for writing reusable Phlex com
341
341
 
342
342
  ### Custom component class
343
343
 
344
- `as:` takes a **field component** — Plutonium constructs it as
344
+ `as:` takes a **field component**: Plutonium constructs it as
345
345
  `YourComponent.new(field, **attributes)`, so it subclasses
346
346
  `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base`
347
347
  (displays) and reads the value off `field`:
@@ -352,7 +352,7 @@ display :chart, as: ChartComponent
352
352
  ```
353
353
 
354
354
  A component with its own constructor (e.g. `PostCardComponent.new(post:)`) is not
355
- an `as:` candidate — it would raise `ArgumentError`. Build it in a block instead:
355
+ an `as:` candidate; it would raise `ArgumentError`. Build it in a block instead:
356
356
 
357
357
  ```ruby
358
358
  display :card do |field|
@@ -453,7 +453,7 @@ end
453
453
  |---|---|
454
454
  | `limit` | Max records (auto-detected from model; default 10) |
455
455
  | `allow_destroy` | Show delete checkbox (auto-detected) |
456
- | `update_only` | Hide "Add" button — only edit existing |
456
+ | `update_only` | Hide "Add" button: only edit existing |
457
457
  | `description` | Help text above the section |
458
458
  | `condition` | Proc to show/hide |
459
459
  | `using` | Another Definition class |
@@ -468,13 +468,13 @@ end
468
468
  end
469
469
  ```
470
470
  - **Don't put `*_attributes` hashes in the policy.** Plutonium extracts nested params from the form definition, not the policy. The policy permits just the association name (`:variants`); `nested_input :variants` handles the rest. Adding `{variants_attributes: [...]}` to `permitted_attributes_for_create` renders as a literal text input. See [Behavior › Policy](/reference/behavior/policies).
471
- - **`update_only: true` hides the Add button** — for `has_one` and "settings"-style associations.
472
- - **Custom class names** — use `class_name:` in the model AND `using:` in the definition.
471
+ - **`update_only: true` hides the Add button**: for `has_one` and "settings"-style associations.
472
+ - **Custom class names**: use `class_name:` in the model AND `using:` in the definition.
473
473
 
474
474
  ## Structured inputs
475
475
 
476
476
  Classless inline fieldsets backed by a JSON/jsonb column. No model associations
477
- required — the whole sub-form is serialised into a single column as a hash
477
+ required: the whole sub-form is serialised into a single column as a hash
478
478
  (single form) or an array of hashes (repeater).
479
479
 
480
480
  ![A single structured input (Payload) and a repeater (Rows)](/images/reference/structured-inputs.png)
@@ -513,16 +513,16 @@ end
513
513
  ### Removing rows
514
514
 
515
515
  Each repeater row has a **Remove** button. Removing a row collapses it to a
516
- compact _Removed — Restore_ bar and disables its inputs, so the browser omits
516
+ compact bar (a _Removed_ label and a **Restore** button) and disables its inputs, so the browser omits
517
517
  them from the submission. The server simply rebuilds the JSON column from the
518
- rows it receives — there is no `_destroy` marker. **Restore** brings the row
518
+ rows it receives; there is no `_destroy` marker. **Restore** brings the row
519
519
  back before saving.
520
520
 
521
521
  ![A removed row collapsed to a Restore bar](/images/reference/structured-inputs-removed.png)
522
522
 
523
523
  ### Policy
524
524
 
525
- Permit the column name as a plain symbol — Plutonium handles the nested hash
525
+ Permit the column name as a plain symbol; Plutonium handles the nested hash
526
526
  params automatically:
527
527
 
528
528
  ```ruby
@@ -544,13 +544,13 @@ attribute is declared automatically; `execute` receives the value as a `Hash`
544
544
  The fields are classless render declarations, so there is nothing for Plutonium
545
545
  to attach validations to (unlike [`nested_input`](#nested-inputs), whose nested
546
546
  records run their own model validations). Whatever the form submits is stored
547
- as-is, after blank rows are dropped — **no per-field server-side validation**.
547
+ as-is, after blank rows are dropped: **no per-field server-side validation**.
548
548
  :::
549
549
 
550
550
  Specifically:
551
551
 
552
552
  - **HTML constraints are client-side only.** A field's `required:` and a
553
- select's `choices:` guide the browser but are **not** enforced on the server —
553
+ select's `choices:` guide the browser but are **not** enforced on the server:
554
554
  an API call or a crafted request can submit anything.
555
555
  - **Selects silently drop unknown values.** If a stored value is not among a
556
556
  `as: :select` field's `choices:`, the `<select>` renders **blank**, and saving
@@ -560,7 +560,7 @@ Specifically:
560
560
  `choices:` can drift. Keep `choices:` a stable superset, or use a free-text
561
561
  input, when values can change over time.
562
562
 
563
- To enforce anything, add the validation yourself — it runs server-side:
563
+ To enforce anything, add the validation yourself; it runs server-side:
564
564
 
565
565
  ```ruby
566
566
  # resource: validate the JSON column on the model
@@ -607,7 +607,7 @@ end
607
607
 
608
608
  The block is evaluated once and stored on the class. Re-declaring `form_layout` in a subclass replaces the parent layout as a unit; per-field `input` config inherits normally.
609
609
 
610
- With no `form_layout` declared the form renders unchanged as a single responsive grid — fully backwards-compatible.
610
+ With no `form_layout` declared the form renders unchanged as a single responsive grid, fully backwards-compatible.
611
611
 
612
612
  ### `section(key, *fields, **opts)`
613
613
 
@@ -615,16 +615,16 @@ Groups a set of fields under an optional heading.
615
615
 
616
616
  | Argument | Description |
617
617
  |---|---|
618
- | `key` | Symbol. `:ungrouped` is reserved — use the `ungrouped` macro instead (raises `ArgumentError` otherwise). |
618
+ | `key` | Symbol. `:ungrouped` is reserved: use the `ungrouped` macro instead (raises `ArgumentError` otherwise). |
619
619
  | `*fields` | Ordered field keys to place in this section. |
620
620
  | `label:` | Section heading. Defaults to `key.to_s.humanize` (e.g. `:shipping_address` → `"Shipping address"`). |
621
621
  | `description:` | Optional help line rendered below the heading. |
622
622
  | `collapsible:` | Boolean (default `false`). Wraps the section in a native `<details>/<summary>` (no JS). |
623
623
  | `collapsed:` | Boolean (default `false`). Initial collapsed state when `collapsible: true`. |
624
- | `columns:` | Positive Integer. Overrides the section grid column count (e.g. `columns: 2`). Omit to use the form's default responsive grid. Must be a positive Integer — any other value raises. (Literal only — not dynamic.) |
625
- | `condition:` | Lambda evaluated in the form instance context — same semantics as `input ..., condition:`. `object`, `current_user`, helpers etc. are all available. A falsey result hides the entire section and withholds its fields (they do not spill into `ungrouped`). |
624
+ | `columns:` | Positive Integer. Overrides the section grid column count (e.g. `columns: 2`). Omit to use the form's default responsive grid. Must be a positive Integer, any other value raises. (Literal only, not dynamic.) |
625
+ | `condition:` | Lambda evaluated in the form instance context: same semantics as `input ..., condition:`. `object`, `current_user`, helpers etc. are all available. A falsey result hides the entire section and withholds its fields (they do not spill into `ungrouped`). |
626
626
 
627
- Every option except `columns:` may be either a literal **or a proc** resolved at render time, following the same arity rule as every other option ([Options that vary per render](#options-that-vary-per-render)): take a `form` argument to read the render context. This makes the layout record-aware — e.g. collapse a section by default only for existing records:
627
+ Every option except `columns:` may be either a literal **or a proc** resolved at render time, following the same arity rule as every other option ([Options that vary per render](#options-that-vary-per-render)): take a `form` argument to read the render context. This makes the layout record-aware, e.g. collapse a section by default only for existing records:
628
628
 
629
629
  ```ruby
630
630
  section :advanced, :seo_title, :notes,
@@ -634,19 +634,19 @@ section :advanced, :seo_title, :notes,
634
634
  ```
635
635
 
636
636
  ::: warning Breaking change in 0.63
637
- Section options previously took a **zero-argument** proc evaluated against the form (`collapsed: -> { object.persisted? }`). They now follow the same rule as every other option, where a zero-argument proc keeps its own binding — and a `form_layout` block is evaluated against the layout builder, so `object` there is a `NameError`.
637
+ Section options previously took a **zero-argument** proc evaluated against the form (`collapsed: -> { object.persisted? }`). They now follow the same rule as every other option, where a zero-argument proc keeps its own binding, and a `form_layout` block is evaluated against the layout builder, so `object` there is a `NameError`.
638
638
 
639
639
  ```ruby
640
640
  - collapsed: -> { object.persisted? }
641
641
  + collapsed: ->(form) { form.object.persisted? }
642
642
  ```
643
643
 
644
- It fails loudly, never silently. `condition:` is unchanged — it is still evaluated against the form and still reads `object` with no argument.
644
+ It fails loudly, never silently. `condition:` is unchanged: it is still evaluated against the form and still reads `object` with no argument.
645
645
  :::
646
646
 
647
- A section that resolves to **zero fields** — every declared field filtered out by the permitted set, or no field assigned — renders nothing at all (no heading, no grid). This keeps forms clean when fewer attributes are permitted than declared (notably `+ New`, where the create policy often permits a subset).
647
+ A section that resolves to **zero fields** (every declared field filtered out by the permitted set, or no field assigned) renders nothing at all (no heading, no grid). This keeps forms clean when fewer attributes are permitted than declared (notably `+ New`, where the create policy often permits a subset).
648
648
 
649
- The same goes for a section whose fields are **all hidden by their own `condition:`** on this render: it disappears with them instead of leaving an empty heading behind. The hidden fields are still recorded on the form, just as a hidden field in a visible section is. So fields that only apply to some records — re-evaluated on `pre_submit` — can be grouped in a section without any extra wiring:
649
+ The same goes for a section whose fields are **all hidden by their own `condition:`** on this render: it disappears with them instead of leaving an empty heading behind. The hidden fields are still recorded on the form, just as a hidden field in a visible section is. So fields that only apply to some records, re-evaluated on `pre_submit`, can be grouped in a section without any extra wiring:
650
650
 
651
651
  ```ruby
652
652
  form_layout do
@@ -667,10 +667,10 @@ section :shipping, :address, :city, :postcode,
667
667
 
668
668
  ### `ungrouped(**opts)`
669
669
 
670
- A macro (not a `section` call) that configures the implicit bucket collecting every permitted field not claimed by any `section`. Takes **no field list** — its fields are computed at render time.
670
+ A macro (not a `section` call) that configures the implicit bucket collecting every permitted field not claimed by any `section`. Takes **no field list**; its fields are computed at render time.
671
671
 
672
672
  - Accepts the same options as `section`: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:`, `condition:`.
673
- - **Position** — where you call `ungrouped` in the block is where leftovers appear. Omit it entirely and leftovers render **last**, after every declared section, with no heading. (Declaring `ungrouped` at the very end is therefore equivalent to omitting it, except that the explicit form lets you add a `label:` and other options.)
673
+ - **Position**: where you call `ungrouped` in the block is where leftovers appear. Omit it entirely and leftovers render **last**, after every declared section, with no heading. (Declaring `ungrouped` at the very end is therefore equivalent to omitting it, except that the explicit form lets you add a `label:` and other options.)
674
674
  - Declaring `ungrouped` more than once in a single `form_layout` raises `ArgumentError`.
675
675
 
676
676
  ```ruby
@@ -688,9 +688,9 @@ end
688
688
 
689
689
  ### Layout references keys; config stays on `input`
690
690
 
691
- `form_layout` and `section` carry section-level options only. All per-field rendering config — `as:`, the field's own `label:`, `choices:`, per-field `condition:`, `pre_submit:`, blocks — remains on the `input` declaration. Layout never duplicates field config.
691
+ `form_layout` and `section` carry section-level options only. All per-field rendering config (`as:`, the field's own `label:`, `choices:`, per-field `condition:`, `pre_submit:`, blocks) remains on the `input` declaration. Layout never duplicates field config.
692
692
 
693
- This includes a field's **column span**. In a section with `columns:`, fields flow into single grid cells by default; a field that declares its own span via `wrapper: {class: "col-span-..."}` keeps it — a field-level span always wins, so you can opt one field back to full width inside a multi-column section:
693
+ This includes a field's **column span**. In a section with `columns:`, fields flow into single grid cells by default; a field that declares its own span via `wrapper: {class: "col-span-..."}` keeps it; a field-level span always wins, so you can opt one field back to full width inside a multi-column section:
694
694
 
695
695
  ```ruby
696
696
  input :notes, wrapper: {class: "col-span-full"} # spans the whole row...
@@ -702,7 +702,7 @@ end
702
702
 
703
703
  ```ruby
704
704
  class ArticleDefinition < ResourceDefinition
705
- # per-field config on input — untouched by form_layout
705
+ # per-field config on input: untouched by form_layout
706
706
  input :body, as: :markdown
707
707
  input :published_at, hint: "Leave blank to save as draft"
708
708
  input :visibility, as: :select, choices: %w[public private unlisted]
@@ -716,13 +716,13 @@ end
716
716
 
717
717
  ### Fields not in the permitted set are skipped
718
718
 
719
- A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there — a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting — is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out. And when every field a section lists is dropped this way, the section's chrome is dropped with it (see the zero-fields note above) — so the same layout can serve a richly-permitted `edit` and a minimal `new` without leaving empty headings behind.
719
+ A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there (a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting) is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out. And when every field a section lists is dropped this way, the section's chrome is dropped with it (see the zero-fields note above), so the same layout can serve a richly-permitted `edit` and a minimal `new` without leaving empty headings behind.
720
720
 
721
721
  ### On interactions
722
722
 
723
- `form_layout` is also available on `Plutonium::Interaction::Base`. The same DSL groups the interaction's `attribute` declarations into sections. Interaction forms (`Plutonium::UI::Form::Interaction`) pick up the layout automatically — no extra wiring needed.
723
+ `form_layout` is also available on `Plutonium::Interaction::Base`. The same DSL groups the interaction's `attribute` declarations into sections. Interaction forms (`Plutonium::UI::Form::Interaction`) pick up the layout automatically, no extra wiring needed.
724
724
 
725
- Dynamic options and `condition:` work here too, with one difference: on an interaction form the form's `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource` — so e.g. `collapsed: ->(form) { form.object.resource.archived? }`, and `condition: -> { object.resource.archived? }` (which is form-evaluated, so it needs no argument).
725
+ Dynamic options and `condition:` work here too, with one difference: on an interaction form the form's `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource`, so e.g. `collapsed: ->(form) { form.object.resource.archived? }`, and `condition: -> { object.resource.archived? }` (which is form-evaluated, so it needs no argument).
726
726
 
727
727
  ```ruby
728
728
  class PublishPostInteraction < Plutonium::Interaction::Base
@@ -742,7 +742,7 @@ end
742
742
 
743
743
  ## Display layout
744
744
 
745
- The show page's counterpart to [`form_layout`](#form-layout). Same DSL and the same resolution rules — first-section-wins ownership, unlisted permitted fields collected into `ungrouped`, absent fields skipped, zero-field sections and sections whose fields are all condition-hidden dropped entirely — applied to the show page instead of the form.
745
+ The show page's counterpart to [`form_layout`](#form-layout). Same DSL and the same resolution rules: first-section-wins ownership, unlisted permitted fields collected into `ungrouped`, absent fields skipped, zero-field sections and sections whose fields are all condition-hidden dropped entirely, applied to the show page instead of the form.
746
746
 
747
747
  ```ruby
748
748
  class PostDefinition < ResourceDefinition
@@ -756,7 +756,7 @@ class PostDefinition < ResourceDefinition
756
756
  end
757
757
  ```
758
758
 
759
- With no `display_layout` declared the show page renders unchanged as a single card holding one responsive grid — fully backwards-compatible.
759
+ With no `display_layout` declared the show page renders unchanged as a single card holding one responsive grid, fully backwards-compatible.
760
760
 
761
761
  ### Independent of `form_layout`
762
762
 
@@ -782,9 +782,9 @@ Raising rather than ignoring the option means a `form_layout` block copied acros
782
782
 
783
783
  ### Section options
784
784
 
785
- `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as [`section(key, *fields, **opts)`](#section-key-fields-opts) minus `columns:`. A collapsible display section behaves exactly as a form one does, `collapsed:` included.
785
+ `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:`, the same set as [`section(key, *fields, **opts)`](#section-key-fields-opts) minus `columns:`. A collapsible display section behaves exactly as a form one does, `collapsed:` included.
786
786
 
787
- Every option except `condition:` may be a proc, resolved at render under the same arity rule the form uses — a zero-arity proc keeps its own binding, a one-arity proc is handed the display:
787
+ Every option except `condition:` may be a proc, resolved at render under the same arity rule the form uses: a zero-arity proc keeps its own binding, a one-arity proc is handed the display:
788
788
 
789
789
  ```ruby
790
790
  section :audit, :created_at, collapsible: true, collapsed: ->(display) { display.object.active? }
@@ -794,11 +794,11 @@ section :audit, :created_at, collapsible: true, collapsed: ->(display) { display
794
794
 
795
795
  ### Rendering
796
796
 
797
- Each section renders as its own card, stacked by a `sections_wrapper` container — so a sectioned show page has **no single outer card**. Fields declared via [`metadata`](#metadata-panel-show-page) are excluded from the sections and render in the metadata panel instead. Section chrome is themeable; see [UI › Displays › Theming](/reference/ui/displays#theming).
797
+ Each section renders as its own card, stacked by a `sections_wrapper` container, so a sectioned show page has **no single outer card**. Fields declared via [`metadata`](#metadata-panel-show-page) are excluded from the sections and render in the metadata panel instead. Section chrome is themeable; see [UI › Displays › Theming](/reference/ui/displays#theming).
798
798
 
799
799
  ## Page width
800
800
 
801
- Detail-style pages — the show page and resource forms — are constrained to a readable column by default. Inputs and values stretch to their container, so at full content width they become ~1200px-wide text boxes: past a comfortable measure, and a long eye-travel between a label and the value beside it.
801
+ Detail-style pages, the show page and resource forms, are constrained to a readable column by default. Inputs and values stretch to their container, so at full content width they become ~1200px-wide text boxes: past a comfortable measure, and a long eye-travel between a label and the value beside it.
802
802
 
803
803
  Index and table pages are deliberately **not** affected; a table wants every pixel.
804
804
 
@@ -816,7 +816,7 @@ end
816
816
  `:sm` `:md` `:lg` `:xl` `:full`. `:full` opts out of any constraint. An unknown value raises `ArgumentError` at declaration rather than silently rendering at some other width.
817
817
 
818
818
  ::: warning Size tokens are relative to their surface
819
- These are the same token *names* [modal sizes](#modals) use, but **not the same widths**. Each surface has its own scale, because the surfaces aren't comparable — a "small page" is reasonably larger than a "small dialog":
819
+ These are the same token *names* [modal sizes](#modals) use, but **not the same widths**. Each surface has its own scale, because the surfaces aren't comparable: a "small page" is reasonably larger than a "small dialog":
820
820
 
821
821
  | Token | Page width | Centered modal | Slideover |
822
822
  |---|---|---|---|
@@ -846,7 +846,7 @@ All three inherit to subclasses, so a portal-specific definition keeps its paren
846
846
 
847
847
  ### Scope
848
848
 
849
- - **Modals are unaffected** — a dialog sizes itself via `modal_size`.
849
+ - **Modals are unaffected**: a dialog sizes itself via `modal_size`.
850
850
  - **Interactions** (`Plutonium::Interaction::Base`) support the same settings, for interactive actions rendered as standalone pages.
851
851
  - **Wizards are configured separately**, via `Plutonium.configuration.wizards.width`. It defaults to `:md` independently of `default_page_width`, so widening resource pages leaves wizard steps where they are. Set both if you want them to match.
852
852
 
@@ -866,9 +866,9 @@ input :documents, as: :uppy,
866
866
 
867
867
  Inside `condition:` procs and block-form `input`/`display`:
868
868
 
869
- - `object` — the record being edited or displayed
869
+ - `object`: the record being edited or displayed
870
870
  - `current_user`
871
- - `current_parent` — parent record for nested resources
871
+ - `current_parent`: parent record for nested resources
872
872
  - `request`, `params`
873
873
  - All view helpers (via the same context as controllers)
874
874
 
@@ -922,23 +922,23 @@ interactive_action_page_breadcrumbs true
922
922
  ```ruby
923
923
  class PostDefinition < ResourceDefinition
924
924
  # "Save and add another" / "Update and continue editing"
925
- # nil (default) — auto: hidden for singular resources, shown for plural
926
- # true — always show
927
- # false — always hide
925
+ # nil (default): auto: hidden for singular resources, shown for plural
926
+ # true: always show
927
+ # false: always hide
928
928
  submit_and_continue false
929
929
 
930
930
  # How :new / :edit and interactive actions render
931
- # :slideover (default) — slide-in panel from the right
932
- # :centered — centered dialog
933
- # false — full standalone pages (no modal)
931
+ # :slideover (default): slide-in panel from the right
932
+ # :centered: centered dialog
933
+ # false: full standalone pages (no modal)
934
934
  # size: optional, one of :sm, :md (default), :lg, :xl, :auto, :full
935
- # (widths are per-surface — see Page width; a slideover's :md is 480px,
935
+ # (widths are per-surface: see Page width; a slideover's :md is 480px,
936
936
  # a centered dialog's is 576px, a page's is 896px)
937
937
  modal :centered, size: :lg
938
938
  end
939
939
  ```
940
940
 
941
- `modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win — see [Actions](./actions).
941
+ `modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win; see [Actions](./actions).
942
942
 
943
943
  ### `show_in` {#show_in}
944
944
 
@@ -951,8 +951,8 @@ end
951
951
 
952
952
  Controls how the **show page** opens when a record is clicked in the table or grid (and serves as the default for a [kanban board](/reference/kanban/dsl#show_in), which can override it per-board):
953
953
 
954
- - `:page` (default) — full-page navigation to the show route.
955
- - `:modal` — the show page opens in a **centered** dialog. This is deliberately independent of `modal:`/`modal_mode` above (which styles `:new`/`:edit`) — show is always centered, never a slideover. From inside the modal an expand icon opens the full page in a new tab; ⌘/Ctrl-click (or middle-click) on the row/card does the same directly.
954
+ - `:page` (default): full-page navigation to the show route.
955
+ - `:modal`, the show page opens in a **centered** dialog. This is deliberately independent of `modal:`/`modal_mode` above (which styles `:new`/`:edit`), show is always centered, never a slideover. From inside the modal an expand icon opens the full page in a new tab; ⌘/Ctrl-click (or middle-click) on the row/card does the same directly.
956
956
 
957
957
  An unknown mode raises `ArgumentError`.
958
958
 
@@ -972,7 +972,7 @@ Behavior:
972
972
  - **Policy-aware.** Fields intersect with the policy's permitted attributes. The panel auto-hides when nothing is permitted.
973
973
  - **Deduplicated.** Fields listed in `metadata` are removed from the main card so values aren't shown twice.
974
974
  - **Responsive.** Side-by-side at `lg+`, stacked below.
975
- - **Formatting inherits.** Field labels and `as:` declarations propagate — the metadata panel uses the same field-rendering machinery as the main card.
975
+ - **Formatting inherits.** Field labels and `as:` declarations propagate: the metadata panel uses the same field-rendering machinery as the main card.
976
976
 
977
977
  ## Index views (Table & Grid)
978
978
 
@@ -980,7 +980,7 @@ Resources can offer both Table and Grid views. The user switches via the toolbar
980
980
 
981
981
  ```ruby
982
982
  class UserDefinition < ResourceDefinition
983
- # No `index_views :table, :grid` needed — declaring grid_fields auto-enables :grid.
983
+ # No `index_views :table, :grid` needed: declaring grid_fields auto-enables :grid.
984
984
  grid_fields(
985
985
  image: :avatar, # ActiveStorage attachment, Shrine, or URL
986
986
  header: :name, # falls back to to_label
@@ -990,7 +990,7 @@ class UserDefinition < ResourceDefinition
990
990
  footer: :last_seen_at # falls back to :created_at
991
991
  )
992
992
 
993
- default_index_view :grid # optional — initial view when no cookie
993
+ default_index_view :grid # optional: initial view when no cookie
994
994
  grid_layout :media # :compact (default) or :media
995
995
  grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
996
996
  end
@@ -1004,13 +1004,13 @@ end
1004
1004
  | `grid_layout :compact \| :media` | `:compact` puts image left of content; `:media` stacks image full-width on top. |
1005
1005
  | `grid_columns N` | Override responsive column count on `lg+`. Default is 1/2/3/4 at sm/md/lg/xl. |
1006
1006
 
1007
- Grid slots — `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer` — are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
1007
+ Grid slots, `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`, are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
1008
1008
 
1009
1009
  Only declare `index_views` explicitly to **disable** one (e.g. `index_views :grid` to drop the table view).
1010
1010
 
1011
1011
  ## Custom page classes
1012
1012
 
1013
- Override the rendered page entirely — full control via Phlex:
1013
+ Override the rendered page entirely: full control via Phlex:
1014
1014
 
1015
1015
  ```ruby
1016
1016
  class PostDefinition < ResourceDefinition
@@ -1037,8 +1037,8 @@ See [UI › Pages](/reference/ui/pages) and [UI › Forms](/reference/ui/forms)
1037
1037
 
1038
1038
  ## Related
1039
1039
 
1040
- - [Query](./query) — search, filters, scopes, sorting
1041
- - [Actions](./actions) — custom + bulk actions
1042
- - [Behavior › Policy](/reference/behavior/policies) — `permitted_attributes_for_*`, authorization
1043
- - [UI › Forms](/reference/ui/forms) — field builder, association inputs, theming
1044
- - [UI › Pages](/reference/ui/pages) — custom page classes
1040
+ - [Query](./query): search, filters, scopes, sorting
1041
+ - [Actions](./actions): custom + bulk actions
1042
+ - [Behavior › Policy](/reference/behavior/policies): `permitted_attributes_for_*`, authorization
1043
+ - [UI › Forms](/reference/ui/forms): field builder, association inputs, theming
1044
+ - [UI › Pages](/reference/ui/pages): custom page classes
@@ -1,7 +1,7 @@
1
1
  # CSV Export
2
2
 
3
3
  Every resource ships with a streamed CSV export, **disabled by default**. It is *not* an
4
- [action](./actions.md) — it streams a file and opens in a new tab — so it is enabled
4
+ [action](./actions.md) (it streams a file and opens in a new tab), so it is enabled
5
5
  through the policy rather than declared with `action :export_csv`. The route
6
6
  (`GET /<resources>/export_csv`) is auto-mounted on every resource; a split "Export"
7
7
  button appears on the index page once the policy permits it.
@@ -25,8 +25,8 @@ The control is a split button with two behaviours:
25
25
 
26
26
  | | Source | Filename |
27
27
  |---|---|---|
28
- | **Export** (primary) | The current view — selected scope + filters + search (the index's `?q`), **all** matching rows (not just the visible page) | `posts_<date>.csv` |
29
- | **Export all** (dropdown) | The entire authorized scope — ignores scope, filters, search, and default scope | `posts_all_<date>.csv` |
28
+ | **Export** (primary) | The current view: selected scope + filters + search (the index's `?q`), **all** matching rows (not just the visible page) | `posts_<date>.csv` |
29
+ | **Export all** (dropdown) | The entire authorized scope: ignores scope, filters, search, and default scope | `posts_all_<date>.csv` |
30
30
 
31
31
  "Export all" always exports everything the user is authorized to read, regardless of the
32
32
  current scope/filters.
@@ -73,12 +73,12 @@ For a column **without** an `export` block, the value is read straight off the r
73
73
  (`record.public_send(name)`):
74
74
 
75
75
  - **Scalars** (strings, numbers, booleans, dates) are written as-is.
76
- - **Associations** render as their display label — the same `display_name_of` the index
77
- uses (e.g. `User #5`, or the record's `to_label`/`name`/`title` if defined) — never
76
+ - **Associations** render as their display label: the same `display_name_of` the index
77
+ uses (e.g. `User #5`, or the record's `to_label`/`name`/`title` if defined), never
78
78
  `#<User:0x…>`. Add an `export` block to export a specific field instead (e.g. the email).
79
79
  - A name that is **neither** an `export` block **nor** a real method on the record renders
80
80
  the placeholder `<<invalid column>>` rather than aborting the (already-streaming) download.
81
- To export a computed or virtual column, give it an `export` block — a `label:`-only
81
+ To export a computed or virtual column, give it an `export` block: a `label:`-only
82
82
  `export` does **not** supply a value, so it too renders the placeholder.
83
83
 
84
84
  ## Notes & limits
@@ -1,14 +1,14 @@
1
1
  # Resource Reference
2
2
 
3
- A **resource** is the unit Plutonium gives you full CRUD for — list, show, create, edit, delete — automatically. It's four cooperating layers, plus an optional fifth for business logic.
3
+ A **resource** is the unit Plutonium gives you full CRUD for (list, show, create, edit, delete) automatically. It's four cooperating layers, plus an optional fifth for business logic.
4
4
 
5
5
  | Layer | File | What it controls |
6
6
  |---|---|---|
7
7
  | [Model](./model) | `app/models/post.rb` | Data, validations, associations |
8
- | [Definition](./definition) | `app/definitions/post_definition.rb` | UI — which fields, how they render, what actions exist |
9
- | Policy | `app/policies/post_policy.rb` | Authorization — see [Behavior › Policy](/reference/behavior/policies) |
10
- | Controller | `app/controllers/posts_controller.rb` | Request handling — see [Behavior › Controller](/reference/behavior/controllers) |
11
- | Interaction *(optional)* | `app/interactions/publish_post_interaction.rb` | Business logic for custom actions — see [Behavior › Interaction](/reference/behavior/interactions) |
8
+ | [Definition](./definition) | `app/definitions/post_definition.rb` | UI: which fields, how they render, what actions exist |
9
+ | Policy | `app/policies/post_policy.rb` | Authorization: see [Behavior › Policy](/reference/behavior/policies) |
10
+ | Controller | `app/controllers/posts_controller.rb` | Request handling: see [Behavior › Controller](/reference/behavior/controllers) |
11
+ | Interaction *(optional)* | `app/interactions/publish_post_interaction.rb` | Business logic for custom actions, see [Behavior › Interaction](/reference/behavior/interactions) |
12
12
 
13
13
  ## How a resource is born
14
14
 
@@ -22,7 +22,7 @@ That single scaffold gives you a working model + migration + controller + policy
22
22
 
23
23
  ## Auto-detection is the default
24
24
 
25
- Plutonium reads your model and renders every attribute automatically — type, label, form widget, display formatter, table column. You only declare overrides:
25
+ Plutonium reads your model and renders every attribute automatically: type, label, form widget, display formatter, table column. You only declare overrides:
26
26
 
27
27
  ```ruby
28
28
  class PostDefinition < Plutonium::Resource::Definition
@@ -33,20 +33,20 @@ end
33
33
  ```
34
34
 
35
35
  ::: warning Don't declare for completeness
36
- A `field :title` with no options that matches what Plutonium would auto-detect is **dead code** — it does nothing and clutters the file. Declare ONLY when you need a different type, an option, a `condition:`, a block, or a custom component.
36
+ A `field :title` with no options that matches what Plutonium would auto-detect is **dead code**; it does nothing and clutters the file. Declare ONLY when you need a different type, an option, a `condition:`, a block, or a custom component.
37
37
  :::
38
38
 
39
39
  ## Sub-pages
40
40
 
41
- - [Model](./model) — `Plutonium::Resource::Record`, `has_cents`, SGID, custom routing, labeling
42
- - [Definition](./definition) — fields, inputs, displays, columns, page chrome, metadata panel, index views
43
- - [Query](./query) — search, filters, scopes, sorting
44
- - [Actions](./actions) — custom actions, bulk actions, interaction integration
45
- - [Positioning & drag-to-reorder](./positioning) — `positioned_on`, `position_on`, the drag grip, `reposition?`
46
- - [CSV Export](./export) — streamed export, opt-in through the policy
41
+ - [Model](./model): `Plutonium::Resource::Record`, `has_cents`, SGID, custom routing, labeling
42
+ - [Definition](./definition): fields, inputs, displays, columns, page chrome, metadata panel, index views
43
+ - [Query](./query): search, filters, scopes, sorting
44
+ - [Actions](./actions): custom actions, bulk actions, interaction integration
45
+ - [Positioning & drag-to-reorder](./positioning): `positioned_on`, `position_on`, the drag grip, `reposition?`
46
+ - [CSV Export](./export): streamed export, opt-in through the policy
47
47
 
48
48
  ## Related
49
49
 
50
- - [Guides › Adding Resources](/guides/adding-resources) — task recipe
51
- - [App › Generators](/reference/app/generators) — `pu:res:scaffold` / `pu:res:conn` reference
52
- - [Tenancy](/reference/tenancy/) — multi-tenant scoping
50
+ - [Guides › Adding Resources](/guides/adding-resources): task recipe
51
+ - [App › Generators](/reference/app/generators): `pu:res:scaffold` / `pu:res:conn` reference
52
+ - [Tenancy](/reference/tenancy/): multi-tenant scoping