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,11 +1,11 @@
1
1
  ---
2
2
  name: plutonium-behavior
3
- description: Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
3
+ description: 'Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".'
4
4
  ---
5
5
 
6
- # Plutonium Behavior — Controllers, Policies, Interactions
6
+ # Plutonium Behavior: Controllers, Policies, Interactions
7
7
 
8
- The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to *write* the controller hook, policy method, or interaction class behind it.
8
+ The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]]; this skill covers how to *write* the controller hook, policy method, or interaction class behind it.
9
9
 
10
10
  For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]].
11
11
 
@@ -16,9 +16,9 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
16
16
  - **`create?` and `read?` default to `false`.** Always override them explicitly. Derived methods (`update?`, `show?`, etc.) inherit automatically.
17
17
  - **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
18
18
  - **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
19
- - **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
20
- - **An interaction is a presentation object** (it can only be built with a `view_context`). Logic may *start* in `execute`; the **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**. Don't pre-extract, and don't invent a service layer. See Part 3 › Where the logic goes.
21
- - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
19
+ - **Return `succeed(...)` or `failed(...)`** from `execute`; the controller can't tell what happened otherwise.
20
+ - **An interaction is a presentation object** (it can only be built with a `view_context`). Logic may *start* in `execute`; the **second caller** (a job, an API controller, a rake task, the console) is the trigger to move it onto the **model**. Don't pre-extract, and don't invent a service layer. See Part 3 › Where the logic goes.
21
+ - **Redirect is automatic on success**: only use `with_redirect_response` for a *different* destination.
22
22
  - **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly. `super` works when extending a parent policy (e.g., a package base) that itself calls it. See [[plutonium-tenancy]].
23
23
  - **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
24
24
  - **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (undefined methods return `false`).
@@ -26,35 +26,35 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
26
26
 
27
27
  ---
28
28
 
29
- ## 🛑 Before you write behavior: place it in the right layer (ASK — don't infer)
29
+ ## 🛑 Before you write behavior: place it in the right layer (ASK: don't infer)
30
30
 
31
31
  "Make X happen" doesn't say **where** X lives. Put it in the wrong layer and you get authorization that doesn't authorize, a 500 on the happy path, or a CRUD override that breaks params/auth. First place the requirement, then confirm names against the real code (next section):
32
32
 
33
33
  | The requirement (in plain words) | Goes in | **NOT** in |
34
34
  |---|---|---|
35
- | "only \<role/owner\> may do X" — *who is allowed* | **Policy** `def x?` | a `condition:` proc — that only hides the button; the route stays live and callable |
36
- | "there's a button that does X" — *the trigger* | **Interaction** (+ action in the definition) | a hand-written controller action; an override of `create`/`update` |
37
- | "doing X changes state / sends mail / charges a card" — *the work* | a named **model** method the interaction calls (`post.publish!`) — inline in `execute` is fine while the button is the only caller | a service-object layer; three chained interactions |
35
+ | "only \<role/owner\> may do X" (*who is allowed*) | **Policy** `def x?` | a `condition:` proc (that only hides the button; the route stays live and callable) |
36
+ | "there's a button that does X" (*the trigger*) | **Interaction** (+ action in the definition) | a hand-written controller action; an override of `create`/`update` |
37
+ | "doing X changes state / sends mail / charges a card" (*the work*) | a named **model** method the interaction calls (`post.publish!`); inline in `execute` is fine while the button is the only caller | a service-object layer; three chained interactions |
38
38
  | "after create/update go to Y" · "munge a param" · "reshape the index query" | **Controller hook** (`redirect_url_after_submit`, `resource_params`, `filtered_resource_collection`) | overriding `create`/`update`/`index` |
39
- | "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition — that only controls *how* a field renders |
39
+ | "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition (that only controls *how* a field renders) |
40
40
 
41
41
  Then resolve the specifics:
42
42
 
43
- 1. **A custom action needs BOTH:** an interaction (the work) **and** a policy `def <action>?` (the authorization). Miss the policy method ⇒ the action silently returns `false` (dead button). Put the role check in `condition:` ⇒ it isn't enforced — a direct POST still runs.
44
- 2. **`create?`/`read?` default to `false`** — override explicitly; derived methods (`update?`/`show?`/…) inherit.
45
- 3. **Any `create!`/`update!`/`save!` in `execute`** ⇒ rescue `ActiveRecord::RecordInvalid` → `failed(e.record.errors)`. Not auto-rescued — otherwise a validation failure 500s.
43
+ 1. **A custom action needs BOTH:** an interaction (the work) **and** a policy `def <action>?` (the authorization). Miss the policy method ⇒ the action silently returns `false` (dead button). Put the role check in `condition:` ⇒ it isn't enforced; a direct POST still runs.
44
+ 2. **`create?`/`read?` default to `false`**: override explicitly; derived methods (`update?`/`show?`/…) inherit.
45
+ 3. **Any `create!`/`update!`/`save!` in `execute`** ⇒ rescue `ActiveRecord::RecordInvalid` → `failed(e.record.errors)`. Not auto-rescued; otherwise a validation failure 500s.
46
46
  4. **`has_cents`** ⇒ permit `:price`, never `:price_cents`.
47
- 5. **New vs editing** — never re-scaffold a controller/policy/interaction that's been customized.
47
+ 5. **New vs editing**: never re-scaffold a controller/policy/interaction that's been customized.
48
48
 
49
- **Never ship a guessed role method, column, enum value, or association as applied code.** `user.finance?`, `record.status_approved?`, `expense.submitted_by` either exist in the app or they don't — confirm them before writing, don't assume. Fall back to `AskUserQuestion` only for genuine product choices (what the rule *should* be), never for facts you can read.
49
+ **Never ship a guessed role method, column, enum value, or association as applied code.** `user.finance?`, `record.status_approved?`, `expense.submitted_by` either exist in the app or they don't. Confirm them before writing; don't assume. Fall back to `AskUserQuestion` only for genuine product choices (what the rule *should* be), never for facts you can read.
50
50
 
51
- ## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
51
+ ## ✅ Before you edit: verify the ground truth (CHECK: read it, don't ask for it)
52
52
 
53
- You have file access — **inspect**; don't ask the user to describe their own app.
53
+ You have file access, **inspect**; don't ask the user to describe their own app.
54
54
 
55
55
  | Check | How | Why it matters |
56
56
  |---|---|---|
57
- | File already customized | Read `app/policies/<x>_policy.rb`, the controller, `app/interactions/*` | Edit incrementally — re-scaffolding clobbers customizations |
57
+ | File already customized | Read `app/policies/<x>_policy.rb`, the controller, `app/interactions/*` | Edit incrementally; re-scaffolding clobbers customizations |
58
58
  | The role/method you authorize on exists | grep the user model for `def finance?` / `enum :role` / `has_role?` | `user.finance?` 500s (or is silently `false`) if absent |
59
59
  | The columns/enum your interaction writes | Read the model + `db/schema.rb` for the enum value, `approved_by`/`approved_at`, the submitter assoc | `update!(status: :approved)` raises if the value/column is missing |
60
60
  | Action not already wired | grep the definition for `action :<x>`; grep the policy for `def <x>?` | Avoids duplicate or dead actions |
@@ -62,18 +62,18 @@ You have file access — **inspect**; don't ask the user to describe their own a
62
62
 
63
63
  Inspect with your own tools **before** proposing code.
64
64
 
65
- ## 🛠 Use the generator — and know what's hand-authored
65
+ ## 🛠 Use the generator, and know what's hand-authored
66
66
 
67
67
  | Task | How | Verify first |
68
68
  |---|---|---|
69
69
  | Base trio (controller + policy + interaction-base) | `pu:res:scaffold` | New resource |
70
70
  | Portal-specific controller/policy | `pu:res:conn … --dest=portal` | Resource exists |
71
- | **A custom-action interaction** | **Hand-author** in `app/interactions/<name>_interaction.rb` (subclass `ResourceInteraction`) — **there is NO `pu:res:interaction` generator; don't invent one** | — |
72
- | Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated — re-scaffolding clobbers it |
71
+ | **A custom-action interaction** | **Hand-author** in `app/interactions/<name>_interaction.rb` (subclass `ResourceInteraction`), **there is NO `pu:res:interaction` generator, so don't invent one** | - |
72
+ | Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated; re-scaffolding clobbers it |
73
73
 
74
74
  ---
75
75
 
76
- # Part 1 — Controllers
76
+ # Part 1: Controllers
77
77
 
78
78
  Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
79
79
 
@@ -87,7 +87,7 @@ end
87
87
 
88
88
  # app/controllers/posts_controller.rb (per resource, generated by pu:res:scaffold)
89
89
  class PostsController < ::ResourceController
90
- # Empty — all CRUD inherited
90
+ # Empty: all CRUD inherited
91
91
  end
92
92
  ```
93
93
 
@@ -111,8 +111,8 @@ Plus interactive-action routes for every action declared in the definition.
111
111
  |---|---|
112
112
  | Field rendering (inputs, displays, columns) | Definition |
113
113
  | Search, filters, scopes, sorting | Definition |
114
- | Custom operations (publish, archive, import) — the *button* | Interaction (+ action in definition) |
115
- | The operation itself, once a job/API/task also needs it | The **model** (`post.publish!`) — see Part 3 › Where the logic goes |
114
+ | Custom operations (publish, archive, import): the *button* | Interaction (+ action in definition) |
115
+ | The operation itself, once a job/API/task also needs it | The **model** (`post.publish!`); see Part 3 › Where the logic goes |
116
116
  | Authorization rules | Policy |
117
117
  | Form/show/page chrome | Definition (custom page classes) |
118
118
  | **Custom redirect logic** | **Controller hook** |
@@ -163,7 +163,7 @@ end
163
163
 
164
164
  **Don't add eager loading unprompted.** Which associations a page renders is decided by the definition, so an `includes` list written now is a guess that goes stale when a column is added. Adding one is a performance change the user didn't ask for.
165
165
 
166
- When a user actually reports a slow index or an N+1: suggest [goldiloader](https://github.com/salsify/goldiloader) first — it eager-loads on traversal, so it tracks whatever the definition renders and needs no list to maintain. Only hand-write `def filtered_resource_collection = super.includes(...)` if they decline the gem, and use the policy's `relation_scope` instead when the association is also read on show/export/typeahead. Full detail: [Guides › Performance](/guides/performance).
166
+ When a user actually reports a slow index or an N+1: suggest [goldiloader](https://github.com/salsify/goldiloader) first: it eager-loads on traversal, so it tracks whatever the definition renders and needs no list to maintain. Only hand-write `def filtered_resource_collection = super.includes(...)` if they decline the gem, and use the policy's `relation_scope` instead when the association is also read on show/export/typeahead. Full detail: [Guides › Performance](/guides/performance).
167
167
 
168
168
  ### Presentation hooks
169
169
 
@@ -180,7 +180,7 @@ def submit_scoped_entity? = true
180
180
 
181
181
  Prefer **interactive actions** (definition + interaction) for anything a user triggers from a page. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
182
182
 
183
- Either way the *operation* is a named model method — the controller and the interaction are two front doors onto the same `post.publish!`.
183
+ Either way the *operation* is a named model method; the controller and the interaction are two front doors onto the same `post.publish!`.
184
184
 
185
185
  ```ruby
186
186
  class PostsController < ::ResourceController
@@ -224,10 +224,10 @@ permitted_attributes
224
224
  current_authorized_scope # Scoped records the user can access
225
225
  ```
226
226
 
227
- **Other resources** (cross-resource auth — use these, not raw `where` / `find`):
227
+ **Other resources** (cross-resource auth; use these, not raw `where` / `find`):
228
228
 
229
229
  ```ruby
230
- authorize! other_record, to: :show? # ActionPolicy — raises if denied
230
+ authorize! other_record, to: :show? # ActionPolicy, raises if denied
231
231
  allowed_to?(:show?, other_record) # Boolean check
232
232
  policy_for(OtherModel) # Policy instance for class or record
233
233
  policy_for(other_record).show?
@@ -237,7 +237,7 @@ authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a re
237
237
  authorized_resource_scope(OtherModel, type: :create) # Different action
238
238
  ```
239
239
 
240
- `authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where` in cross-resource controller code** — otherwise you bypass that resource's tenancy and visibility rules.
240
+ `authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where` in cross-resource controller code**; otherwise you bypass that resource's tenancy and visibility rules.
241
241
 
242
242
  ### Definition access
243
243
 
@@ -294,7 +294,7 @@ end
294
294
 
295
295
  The parent class and association come from the **route** (each nested route carries its registration key), not from parsing the URL. There is no `parent_route_param`.
296
296
 
297
- Parent fields are excluded from forms/displays by default — toggle with the presentation hooks above. For `has_one` associations, routes are singular (no `:id`); index redirects to show (or new if no record exists). See [[plutonium-tenancy]] for the full nested-routing story.
297
+ Parent fields are excluded from forms/displays by default; toggle with the presentation hooks above. For `has_one` associations, routes are singular (no `:id`); index redirects to show (or new if no record exists). See [[plutonium-tenancy]] for the full nested-routing story.
298
298
 
299
299
  ## Entity scoping (multi-tenancy)
300
300
 
@@ -328,7 +328,7 @@ verify_current_authorized_scope # all except new/create
328
328
  Skip only when handling auth manually. Two forms:
329
329
 
330
330
  ```ruby
331
- # Class-level — skip across multiple actions
331
+ # Class-level: skip across multiple actions
332
332
  class PostsController < ::ResourceController
333
333
  skip_verify_authorize_current only: [:custom_action]
334
334
  skip_verify_current_authorized_scope only: [:custom_action]
@@ -338,7 +338,7 @@ class PostsController < ::ResourceController
338
338
  end
339
339
  end
340
340
 
341
- # Per-action — bang methods, call inside the action body
341
+ # Per-action: bang methods, call inside the action body
342
342
  def custom_action
343
343
  skip_verify_authorize_current!
344
344
  skip_verify_current_authorized_scope!
@@ -346,7 +346,7 @@ def custom_action
346
346
  end
347
347
  ```
348
348
 
349
- Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.
349
+ Prefer the per-action bang form when only one action skips; it keeps the exception co-located with the code that needs it.
350
350
 
351
351
  ## Portal-specific controllers
352
352
 
@@ -358,7 +358,7 @@ class AdminPortal::PostsController < ::PostsController
358
358
  include AdminPortal::Concerns::Controller
359
359
  end
360
360
 
361
- # No feature package — inherits portal base
361
+ # No feature package: inherits portal base
362
362
  class AdminPortal::PostsController < AdminPortal::ResourceController
363
363
  end
364
364
  ```
@@ -375,7 +375,7 @@ end
375
375
 
376
376
  ---
377
377
 
378
- # Part 2 — Policies
378
+ # Part 2: Policies
379
379
 
380
380
  Built on [ActionPolicy](https://actionpolicy.evilmartians.io/). Plutonium adds:
381
381
 
@@ -434,9 +434,9 @@ end
434
434
  | `search?` | `index?` | Search-specific rules |
435
435
  | `typeahead?` | `index?` | Autocomplete-specific rules |
436
436
 
437
- 🚨 **`record` is the resource CLASS on collection routes** (`current_policy_subject = resource_record? || resource_class`). `read?` backs both `show?` (instance) and `index?` (class); `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates (incl. kanban column actions) are class-backed too. `def read? = record.published?` raises `NoMethodError` on index — filter the list in `relation_scope`, gate individual records in `show?`. Record actions (`publish?` etc.) and bulk actions are always evaluated per record instance — no type guard needed.
437
+ 🚨 **`record` is the resource CLASS on collection routes** (`current_policy_subject = resource_record? || resource_class`). `read?` backs both `show?` (instance) and `index?` (class); `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates (incl. kanban column actions) are class-backed too. `def read? = record.published?` raises `NoMethodError` on index, so filter the list in `relation_scope` and gate individual records in `show?`. Record actions (`publish?` etc.) and bulk actions are always evaluated per record instance, so no type guard is needed.
438
438
 
439
- `export_csv?` is the exception — it defaults to `false` (not derived) so CSV export is strictly opt-in. Override it to `true` (or `index?`) to enable the built-in export. The exported column set is `permitted_attributes_for_export` (defaults to `permitted_attributes_for_index`). See [[plutonium-resource]] → CSV Export.
439
+ `export_csv?` is the exception: it defaults to `false` (not derived) so CSV export is strictly opt-in. Override it to `true` (or `index?`) to enable the built-in export. The exported column set is `permitted_attributes_for_export` (defaults to `permitted_attributes_for_index`). See [[plutonium-resource]] → CSV Export.
440
440
 
441
441
  ### Custom actions
442
442
 
@@ -448,7 +448,7 @@ def archive? = create? && !record.archived?
448
448
  def invite_user? = user.admin?
449
449
  ```
450
450
 
451
- ### Bulk actions — per-record auth
451
+ ### Bulk actions: per-record auth
452
452
 
453
453
  ```ruby
454
454
  def bulk_archive?
@@ -461,7 +461,7 @@ How it works:
461
461
  - Policy is checked **per record** in the selected set.
462
462
  - **Backend:** if any record fails, the entire request is rejected.
463
463
  - **UI:** only actions ALL selected records support are shown (intersection).
464
- - Records come from `current_authorized_scope` — users can only select what they're allowed to access.
464
+ - Records come from `current_authorized_scope`, so users can only select what they're allowed to access.
465
465
 
466
466
  ## Attribute permissions
467
467
 
@@ -499,7 +499,7 @@ def permitted_attributes_for_read
499
499
  end
500
500
  ```
501
501
 
502
- 🚨 **Index has no `record`.** `permitted_attributes_for_index` is evaluated at collection level — `record` is `nil`. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record. So if you write a record-dependent `_for_read`:
502
+ 🚨 **Index has no record instance.** On collection routes the policy subject is the resource **class** (`current_policy_subject = resource_record? || resource_class`), so `permitted_attributes_for_index` runs with `record == Post`. The same class-bound policy feeds the table, the CSV export (`_for_export` defaults to `_for_index`) and kanban cards. `permitted_attributes_for_show` (and `_for_read`) run per record. So if you write a record-dependent `_for_read`:
503
503
 
504
504
  ```ruby
505
505
  def permitted_attributes_for_read
@@ -509,7 +509,7 @@ def permitted_attributes_for_read
509
509
  end
510
510
  ```
511
511
 
512
- …you MUST also define an explicit `permitted_attributes_for_index` — otherwise inheritance kicks in, runs the `_for_read` body during the table render, and `record.archived?` blows up on `NoMethodError: undefined method 'archived?' for nil`.
512
+ …you MUST also define an explicit `permitted_attributes_for_index` whose body never touches `record`. Otherwise `_for_index` falls back to `_for_read`, runs it against the class during the table render, and `record.archived?` raises `NoMethodError` (`undefined method 'archived?'` for the `Post` class).
513
513
 
514
514
  ```ruby
515
515
  def permitted_attributes_for_index
@@ -517,13 +517,26 @@ def permitted_attributes_for_index
517
517
  end
518
518
  ```
519
519
 
520
- Same rule for `permitted_attributes_for_create` vs `_for_new` (new has no persisted record).
520
+ Index columns are therefore decided **once for the whole collection**: a field is either a column for every row or for none. "Hide X on rows in state Y" cannot be done through the policy; drop the column, keep it for all rows, or (if the user wants a blank cell) render the cell conditionally in a definition `column` block; see [[plutonium-resource]]), and say which one you chose.
521
521
 
522
- ### Policy vs definition — what controls what
522
+ Same rule for `permitted_attributes_for_create` vs `_for_new`: `new` is a collection route too, so `record` is the class there.
523
+
524
+ **Gate on the state the user named, not on its complement.** "Show the price once it's active" is `record.active?`, not `!record.draft?`. With `enum :status, {draft:, active:, discontinued:}` the complement also lets `discontinued` through, and a later enum value would leak too. Remove every attribute that carries the value, e.g. both `:price` and `:price_cents` if the policy lists both:
525
+
526
+ ```ruby
527
+ def permitted_attributes_for_read
528
+ attrs = %i[name sku status price price_cents]
529
+ record.active? ? attrs : attrs - %i[price price_cents]
530
+ end
531
+
532
+ def permitted_attributes_for_index = %i[name sku status price] # class-safe
533
+ ```
534
+
535
+ ### Policy vs definition: what controls what
523
536
 
524
537
  `permitted_attributes_for_*` controls **which fields appear** on a view. Definition `field`/`input`/`display`/`column` declarations only control **how** they render. A `field :name` in the definition does nothing unless `:name` is also in the relevant `permitted_attributes_for_*`.
525
538
 
526
- Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.
539
+ Common mistake: adding a definition declaration and wondering why the field doesn't show; check the policy.
527
540
 
528
541
  ### Anti-pattern: nested-attributes hashes
529
542
 
@@ -553,21 +566,38 @@ def permitted_associations
553
566
  end
554
567
  ```
555
568
 
556
- Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
569
+ Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association, each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
557
570
 
558
571
  Each named association must:
559
572
 
560
573
  - Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
561
574
  - Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).
562
575
 
576
+ 🚨 **"Registered" means registered in the portal rendering the page.** `registered_resources` is the current portal engine's `resource_register`, so a policy shared by several portals can only list associations whose class every one of those portals registers (`register_resource` in its `config/routes.rb`). Otherwise that portal's show page raises, it does not just drop the tab:
577
+
578
+ ```
579
+ ArgumentError: Catalog::Product#product_metadata defined in #permitted_associations, but Catalog::ProductMetadata is not a registered resource
580
+ ```
581
+
582
+ Before adding an association, grep every portal's `config/routes.rb` for the child class and find which portals use the policy (the package policy, unless the portal has its own override). For "only admins get the tab", leave the shared policy alone and add it in a portal-specific policy (next section):
583
+
584
+ ```ruby
585
+ # rails g pu:res:conn Catalog::Product --dest=admin_portal --policy
586
+ class AdminPortal::Catalog::ProductPolicy < ::Catalog::ProductPolicy
587
+ include AdminPortal::ResourcePolicy
588
+
589
+ def permitted_associations = [*super, :product_metadata]
590
+ end
591
+ ```
592
+
563
593
  This is **NOT** the same as:
564
594
 
565
- - **Nested forms** — declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [[plutonium-resource]] › Nested Inputs.
566
- - **Association fields on tables / show details** — controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
595
+ - **Nested forms**: declared with `nested_input :variants` in the definition; requires `accepts_nested_attributes_for` on the model. See [[plutonium-resource]] › Nested Inputs.
596
+ - **Association fields on tables / show details**: controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
567
597
 
568
598
  ## Collection scoping (`relation_scope`)
569
599
 
570
- Filter which records the user can see. **Always compose with `default_relation_scope(relation)` explicitly** — `super` is unreliable inside the block, and bypassing this triggers `verify_default_relation_scope_applied!`:
600
+ Filter which records the user can see. **Always compose with `default_relation_scope(relation)` explicitly**: `super` is unreliable inside the block, and bypassing this triggers `verify_default_relation_scope_applied!`:
571
601
 
572
602
  ```ruby
573
603
  relation_scope do |relation|
@@ -580,6 +610,8 @@ For tenant scoping, parent scoping, `skip_default_relation_scope!`, and `associa
580
610
 
581
611
  ## Portal-specific policies
582
612
 
613
+ Generate the override with `rails g pu:res:conn <Resource> --dest=<portal> --policy` (`--policy` forces the file even when a base policy exists). It subclasses the base policy and includes the portal's `ResourcePolicy`, so override only what differs and call `super` for the rest.
614
+
583
615
  ```ruby
584
616
  class PostPolicy < ResourcePolicy
585
617
  def create? = user.present?
@@ -667,7 +699,7 @@ end
667
699
 
668
700
  ---
669
701
 
670
- # Part 3 — Interactions
702
+ # Part 3: Interactions
671
703
 
672
704
  An interaction is the entry point from a Plutonium page into an operation: it declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash + redirect. Registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
673
705
 
@@ -685,13 +717,13 @@ def initialize(view_context:, **attributes)
685
717
 
686
718
  | | |
687
719
  |---|---|
688
- | **Logic may start in `execute`** | A one-off with a single caller is fine inline. Don't pre-extract — Plutonium ships no service layer to put it in, and YAGNI. |
720
+ | **Logic may start in `execute`** | A one-off with a single caller is fine inline. Don't pre-extract: Plutonium ships no service layer to put it in, and YAGNI. |
689
721
  | **The trigger to extract is the second caller** | A background job, an API controller, a rake task, the console, another interaction. |
690
- | **The destination is the model** | Fat models, per Rails convention. Name it in domain language — `publish!`, `archive!`, `register!` — not persistence (`update_published_at`). Never a `PublishPostService`. |
722
+ | **The destination is the model** | Fat models, per Rails convention. Name it in domain language (`publish!`, `archive!`, `register!`), not persistence (`update_published_at`). Never a `PublishPostService`. |
691
723
 
692
724
  ```ruby
693
725
  # 🚫 Three interactions, three view_contexts. A signup API or a seeds script
694
- # can supply none of them — the email and the audit row are stranded.
726
+ # can supply none of them, so the email and the audit row are stranded.
695
727
  CreateUserInteraction.call(view_context:, **user_params)
696
728
  .and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
697
729
  .and_then { |user| LogActivity.call(view_context:, user:) }
@@ -712,14 +744,14 @@ Chaining three interactions is usually one model method wearing three presentati
712
744
  class ResourceInteraction < Plutonium::Resource::Interaction
713
745
  end
714
746
 
715
- # app/models/post.rb — what publishing MEANS (a scheduler job can call this too)
747
+ # app/models/post.rb: what publishing MEANS (a scheduler job can call this too)
716
748
  class Post < ApplicationRecord
717
749
  def publish!(on: Time.current)
718
750
  update!(published: true, published_at: on)
719
751
  end
720
752
  end
721
753
 
722
- # A real interaction — the button in front of it
754
+ # A real interaction: the button in front of it
723
755
  class PublishPostInteraction < ResourceInteraction
724
756
  presents label: "Publish",
725
757
  icon: Phlex::TablerIcons::Send,
@@ -758,7 +790,7 @@ attribute :metadata, :hash
758
790
  attribute :date, :datetime
759
791
  ```
760
792
 
761
- The presence of `:resource` / `:resources` / neither determines the action type — see [[plutonium-resource]] › Action Types.
793
+ The presence of `:resource` / `:resources` / neither determines the action type; see [[plutonium-resource]] › Action Types.
762
794
 
763
795
  ### Structured / repeating input
764
796
 
@@ -778,7 +810,7 @@ end
778
810
  ```
779
811
 
780
812
  ⚠️ **`nested_input` and `accepts_nested_attributes_for` are NOT available on
781
- interactions** (they were model-backed). Use `structured_input` instead — it's
813
+ interactions** (they were model-backed). Use `structured_input` instead; it's
782
814
  classless and collects plain hashes/arrays. See [[plutonium-resource]] ›
783
815
  Structured Inputs for options (`repeat:`, `using:`, `fields:`).
784
816
 
@@ -809,7 +841,7 @@ MyInteraction.description
809
841
 
810
842
  If `action :foo, interaction: FooInteraction` doesn't override `label:`/`icon:`/etc., these `presents` values are used.
811
843
 
812
- ## `execute` — outcomes
844
+ ## `execute` outcomes
813
845
 
814
846
  `execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
815
847
 
@@ -832,9 +864,9 @@ failed(email: "is invalid", name: "is required") # hash form
832
864
  failed("Invalid value", :email) # string + attribute
833
865
  ```
834
866
 
835
- ### Chaining — `and_then`
867
+ ### Chaining with `and_then`
836
868
 
837
- On a `Success`, `and_then` yields **the value** (NOT the outcome — there is no `r.value` in the block) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
869
+ On a `Success`, `and_then` yields **the value** (NOT the outcome; there is no `r.value` in the block) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
838
870
 
839
871
  Use it to compose outcomes **inside one `execute`**, e.g. a guard:
840
872
 
@@ -853,11 +885,11 @@ def unlocked_resource
853
885
  end
854
886
  ```
855
887
 
856
- ⚠️ **Don't chain interactions to sequence business operations** — see Where the logic goes above.
888
+ ⚠️ **Don't chain interactions to sequence business operations**; see Where the logic goes above.
857
889
 
858
890
  ## Validations
859
891
 
860
- Standard ActiveModel — run automatically before `execute`; if they fail, `execute` never runs:
892
+ Standard ActiveModel, run automatically before `execute`; if they fail, `execute` never runs:
861
893
 
862
894
  ```ruby
863
895
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
@@ -876,16 +908,16 @@ end
876
908
 
877
909
  | | Interaction validation | Model validation |
878
910
  |---|---|---|
879
- | Asks | "Can I read this input?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who calls |
911
+ | Asks | "Can I read this input?" (present, parses, right type, plausible format) | "Is this record legal?" (invariants that hold no matter who calls) |
880
912
  | Exists to | render a form error next to the field | protect the data from every caller, including ones with no form |
881
- | Runs | before `execute`, never touching the model | inside `save!`/`update!` — i.e. inside the model method |
913
+ | Runs | before `execute`, never touching the model | inside `save!`/`update!`, i.e. inside the model method |
882
914
 
883
915
  They surface **differently**, and that should inform where a rule lives:
884
916
 
885
917
  - An interaction validation attaches to a declared attribute → the re-rendered modal shows it inline against that input **and** in the summary.
886
918
  - `failed(record.errors)` flattens `ActiveModel::Errors` to **full messages on `:base`** (`Array(errors)` → `errors.to_a` → `full_messages`) → error summary only, never against a field, phrased with the *model's* attribute names.
887
919
 
888
- So it's fine — often right — to **duplicate** a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: when a job calls `post.publish!`, the interaction's validations aren't in the picture.
920
+ So it's fine, often right, to **duplicate** a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: when a job calls `post.publish!`, the interaction's validations aren't in the picture.
889
921
 
890
922
  ## Accessing context
891
923
 
@@ -897,7 +929,7 @@ def execute
897
929
  end
898
930
  ```
899
931
 
900
- This write is **correctly inline**. "Who clicked the button" is context only the presentation layer holds — a job has no answer for it, so there's no second caller to extract for.
932
+ This write is **correctly inline**. "Who clicked the button" is context only the presentation layer holds (a job has no answer for it), so there's no second caller to extract for.
901
933
 
902
934
  A shorter `current_user` helper is conventional:
903
935
 
@@ -921,15 +953,15 @@ def current_user = view_context.controller.helpers.current_user
921
953
  Use `resource_url_for` with the `interaction:` kwarg. Action type is inferred from the element and presence of `ids:`:
922
954
 
923
955
  ```ruby
924
- # Record action — instance argument
956
+ # Record action: instance argument
925
957
  resource_url_for(@post, interaction: :publish)
926
958
  # => /posts/:id/record_actions/publish
927
959
 
928
- # Resource action — class, no ids
960
+ # Resource action: class, no ids
929
961
  resource_url_for(Post, interaction: :import)
930
962
  # => /posts/resource_actions/import
931
963
 
932
- # Bulk action — class + ids
964
+ # Bulk action: class + ids
933
965
  resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
934
966
  # => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
935
967
 
@@ -937,14 +969,14 @@ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
937
969
  resource_url_for(@post, parent: @user, interaction: :publish)
938
970
  ```
939
971
 
940
- The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
972
+ The same URL serves GET (form/confirmation) and POST (commit); the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
941
973
 
942
974
  ## Complete example
943
975
 
944
- Inviting a user is a textbook second-caller case — a seats-provisioning job, an admin rake task, and a signup API all need to send the same invitation. So the operation lives on `Company`; the interaction is the button in front of it.
976
+ Inviting a user is a textbook second-caller case: a seats-provisioning job, an admin rake task, and a signup API all need to send the same invitation. So the operation lives on `Company`; the interaction is the button in front of it.
945
977
 
946
978
  ```ruby
947
- # app/models/company.rb — what inviting MEANS: the row, the mail, the audit trail
979
+ # app/models/company.rb: what inviting MEANS: the row, the mail, the audit trail
948
980
  class Company < ApplicationRecord
949
981
  has_many :user_invites
950
982
 
@@ -971,7 +1003,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
971
1003
  input :email
972
1004
  input :role, as: :select, choices: -> { UserInvite.roles.keys }
973
1005
 
974
- # Input shape only — readable email? a role that exists?
1006
+ # Input shape only: readable email? a role that exists?
975
1007
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
976
1008
  validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
977
1009
  validate :not_already_invited
@@ -1001,7 +1033,7 @@ end
1001
1033
 
1002
1034
  ## Related Skills
1003
1035
 
1004
- - [[plutonium-resource]] — registering interactions as actions; field/input/display syntax
1005
- - [[plutonium-tenancy]] — `relation_scope`, entity scoping, nested resources
1006
- - [[plutonium-ui]] — custom interaction form templates, page classes
1007
- - [[plutonium-testing]] — testing controllers, policies, interactions
1036
+ - [[plutonium-resource]]: registering interactions as actions; field/input/display syntax
1037
+ - [[plutonium-tenancy]]: `relation_scope`, entity scoping, nested resources
1038
+ - [[plutonium-ui]]: custom interaction form templates, page classes
1039
+ - [[plutonium-testing]]: testing controllers, policies, interactions
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: plutonium-dashboard
3
- description: Use BEFORE building a dashboard, KPI page, metrics overview or chart page in a Plutonium app — Plutonium::Dashboard::Base, the metric / chart / card DSL, register_dashboard, lazy turbo-frame cards, refresh, conditions, authorize?, and the pu:dashboard generator. The single source for "how do I add a dashboard with metric cards and charts".
3
+ description: 'Use BEFORE building a dashboard, KPI page, metrics overview or chart page in a Plutonium app: Plutonium::Dashboard::Base, the metric / chart / card DSL, register_dashboard, lazy turbo-frame cards, refresh, conditions, authorize?, and the pu:dashboard generator. The single source for "how do I add a dashboard with metric cards and charts".'
4
4
  ---
5
5
 
6
6
  # Plutonium Dashboards
7
7
 
8
- A dashboard is a class of cards (`metric`, `chart`, `card`) mounted in a portal with one routes line. Every card loads in its own lazy turbo frame, so the page paints at once and each card's queries run in a separate request. Charts render with Chart.js through Chartkick, styled by Plutonium's design tokens.
8
+ A dashboard is a class of cards (`metric`, `chart`, `card`) mounted in a portal with one routes line. By default every card loads in its own lazy turbo frame, so the page paints at once and each card's queries run in a separate request. Charts render with Chart.js through Chartkick, styled by Plutonium's design tokens.
9
9
 
10
10
  For resources and their definitions see [[plutonium-resource]]; for portals and routes see [[plutonium-app]]; for custom Phlex markup inside a card see [[plutonium-ui]].
11
11
 
@@ -19,7 +19,8 @@ For resources and their definitions see [[plutonium-resource]]; for portals and
19
19
  - **Root-qualify packaged models inside a portal dashboard.** Within `module AdminPortal`, `Blogging::Post` resolves to the portal's own `AdminPortal::Blogging` controller namespace and raises `NameError`; write `::Blogging::Post`.
20
20
  - **`metric` and `chart` blocks return data; `card` blocks render Phlex markup.** A `card` block is `instance_exec`ed in a Phlex component: `div`, `ul`, `render` work, and unknown methods forward to the dashboard.
21
21
  - **`condition:` gates the card endpoint too** (404 when false), unlike a field `condition:`. Page-level access is `authorize?` on the dashboard (403).
22
- - **`refresh:` needs `lazy: true`** (the default). It reloads the frame; an inline card has no frame. `refresh: false` opts a lazy card out of the dashboard's `refresh`.
22
+ - **`refresh:` needs `lazy: true`** (the default). It reloads the frame; an inline card has no frame. `refresh: false` opts a lazy card out of the dashboard's `refresh`. A card is inline or refreshing, never both: when a request wants both for one card, say so and let the user pick (see [Lazy vs inline](#lazy-vs-inline-lazy-false)).
23
+ - **Work within the DSL in an app; don't patch the gem.** Removing the `refresh:` + `lazy: false` guard, teaching the board to refresh inline cards, or hand-rolling polling (a custom Stimulus timer, meta refresh, a Turbo stream broadcast) is not the fix for a dashboard request. Gem edits change behavior for every app and ship only with a release; a custom poller duplicates the built-in `frame-refresh` controller.
23
24
  - **Chart options other than `type:` / `height:` pass straight to Chartkick.** Unknown metric options raise.
24
25
  - **Ejected sidebars don't list dashboards automatically.** Portals generated before this feature have their own `_resource_sidebar.html.erb`; add the `registered_dashboards` block (below), which groups them under a "Dashboards" parent after the Home link (`plutonium.resource.nav.home`).
25
26
 
@@ -108,7 +109,7 @@ register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
108
109
  - Entity-scoped portals: URLs carry the scope segment; use `dashboard_path_for(Klass)` rather than a hand-built helper.
109
110
  - Every non-root mount is drawn under `dashboards/` (`at: "sales"` → `/admin/dashboards/sales`), so it cannot collide with a resource route. Helper names carry no prefix. `prefix: nil` mounts at the bare path (`/admin/sales`, keeping it clear of resource routes is then on you); `prefix: "reports"` swaps the segment.
110
111
  - Lazy vs inline: see the table below.
111
- - Errors in a card: raised in development/test. In production the card is replaced by a notice, and the error is logged and reported via `Rails.error` (`source: "plutonium.dashboard"`, `context: {dashboard:, card:}`).
112
+ - Errors in a card: raised when `config.consider_all_requests_local` is true (development and test by default). Otherwise (production) the card is replaced by a notice, and the error is logged and reported via `Rails.error` (`source: "plutonium.dashboard"`, `context: {dashboard:, card:}`).
112
113
 
113
114
  ## Lazy vs inline (`lazy: false`)
114
115
 
@@ -124,6 +125,12 @@ register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
124
125
 
125
126
  Default to lazy. Use `lazy: false` only for a cheap value (cached number, indexed count) near the top of the page; one slow inline card delays the whole page.
126
127
 
128
+ When someone asks for a card to "render with the page" (usually to stop the skeleton flash), make the trade explicit in your answer rather than just flipping the flag:
129
+
130
+ - **The page waits on it.** The block's query runs inside the page request, so its time is added to every page load.
131
+ - **An exception takes the page down in development and test.** The error is re-raised while rendering the page, so the whole dashboard 500s, not just that card. In production it degrades to the card's notice like a lazy card.
132
+ - **It stops refreshing.** If the same card should also auto-refresh, offer the two options: inline with no refresh (updates on page reload), or keep it lazy so it refreshes. A lazy card shows its skeleton only on the first load; each refresh reloads the frame with `refresh: "morph"` and swaps the content in place without a skeleton.
133
+
127
134
  ## Sidebar (ejected partial)
128
135
 
129
136
  ```erb