plutonium 0.52.0 → 0.53.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +6 -4
  3. data/.claude/skills/plutonium-tenancy/SKILL.md +9 -4
  4. data/.claude/skills/plutonium-ui/SKILL.md +29 -5
  5. data/CHANGELOG.md +16 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +257 -11
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +39 -39
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +2 -1
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/guides/authentication.md +1 -1
  14. data/docs/guides/custom-actions.md +2 -1
  15. data/docs/guides/customizing-ui.md +6 -5
  16. data/docs/guides/multi-tenancy.md +6 -6
  17. data/docs/guides/theming.md +1 -1
  18. data/docs/public/images/components/avatar.png +0 -0
  19. data/docs/reference/auth/accounts.md +1 -1
  20. data/docs/reference/behavior/policies.md +1 -1
  21. data/docs/reference/configuration.md +61 -0
  22. data/docs/reference/resource/actions.md +2 -1
  23. data/docs/reference/resource/definition.md +4 -3
  24. data/docs/reference/tenancy/entity-scoping.md +12 -13
  25. data/docs/reference/ui/components.md +53 -0
  26. data/docs/reference/ui/forms.md +1 -1
  27. data/docs/reference/ui/pages.md +6 -5
  28. data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
  33. data/lib/plutonium/action/base.rb +43 -63
  34. data/lib/plutonium/configuration.rb +7 -0
  35. data/lib/plutonium/definition/actions.rb +10 -11
  36. data/lib/plutonium/definition/base.rb +29 -0
  37. data/lib/plutonium/helpers/assets_helper.rb +0 -30
  38. data/lib/plutonium/helpers/content_helper.rb +0 -44
  39. data/lib/plutonium/helpers/display_helper.rb +0 -62
  40. data/lib/plutonium/helpers/turbo_helper.rb +0 -4
  41. data/lib/plutonium/helpers.rb +0 -2
  42. data/lib/plutonium/resource/definition.rb +0 -42
  43. data/lib/plutonium/ui/action_button.rb +4 -3
  44. data/lib/plutonium/ui/avatar.rb +182 -0
  45. data/lib/plutonium/ui/component/kit.rb +2 -0
  46. data/lib/plutonium/ui/form/base.rb +16 -2
  47. data/lib/plutonium/ui/form/components/secure_association.rb +3 -2
  48. data/lib/plutonium/ui/form/resource.rb +58 -0
  49. data/lib/plutonium/ui/form/theme.rb +7 -3
  50. data/lib/plutonium/ui/grid/card.rb +10 -26
  51. data/lib/plutonium/ui/modal/base.rb +36 -1
  52. data/lib/plutonium/ui/modal/centered.rb +24 -6
  53. data/lib/plutonium/ui/modal/slideover.rb +26 -11
  54. data/lib/plutonium/ui/nav_user.rb +3 -23
  55. data/lib/plutonium/ui/page/edit.rb +6 -3
  56. data/lib/plutonium/ui/page/interactive_action.rb +5 -3
  57. data/lib/plutonium/ui/page/new.rb +6 -3
  58. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
  59. data/lib/plutonium/version.rb +1 -1
  60. data/package.json +1 -1
  61. data/src/css/components.css +38 -1
  62. data/src/css/slim_select.css +3 -2
  63. data/src/js/controllers/dirty_form_guard_controller.js +165 -0
  64. data/src/js/controllers/register_controllers.js +2 -0
  65. data/src/js/controllers/remote_modal_controller.js +53 -19
  66. data/src/js/turbo/index.js +1 -0
  67. data/src/js/turbo/turbo_confirm.js +128 -0
  68. metadata +10 -6
  69. data/lib/plutonium/helpers/attachment_helper.rb +0 -73
  70. data/lib/plutonium/helpers/table_helper.rb +0 -35
  71. /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
@@ -7,7 +7,8 @@
7
7
  <%=
8
8
  render Plutonium::UI::NavUser.new(
9
9
  name: nil,
10
- email: current_user.try(:email) || current_user
10
+ email: current_user.try(:email) || current_user,
11
+ record: (current_user if current_user.respond_to?(:id))
11
12
  ) do |nav|
12
13
  if try(:profile_url)
13
14
  nav.with_section do |section|
@@ -108,6 +108,7 @@ export default defineConfig(withMermaid({
108
108
  text: "Reference",
109
109
  items: [
110
110
  { text: "Overview", link: "/reference/" },
111
+ { text: "Configuration", link: "/reference/configuration" },
111
112
  ]
112
113
  },
113
114
  {
@@ -70,7 +70,7 @@ The task creates the account and triggers a verification email; the admin sets t
70
70
  rails generate pu:saas:setup --user Customer --entity Organization
71
71
  ```
72
72
 
73
- ⚠️ This is a **meta-generator** — it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup-pu-saas-setup).
73
+ ⚠️ This is a **meta-generator** — it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
74
74
 
75
75
  ### API-only (JWT)
76
76
 
@@ -186,7 +186,8 @@ action :name,
186
186
 
187
187
  # Behavior
188
188
  confirmation: "Are you sure?",
189
- modal: :slideover # :centered (default) or :slideover
189
+ modal: :slideover, # :slideover / :centered overrides definition's modal mode
190
+ size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
190
191
  ```
191
192
 
192
193
  Full options: [Reference › Resource › Actions › Action options](/reference/resource/actions#action-options).
@@ -162,17 +162,18 @@ end
162
162
 
163
163
  ## Modals and slideovers
164
164
 
165
- By default `:new` and `:edit` render in a slideover panel. Switch to a centered modal or a full standalone page from the definition:
165
+ By default `:new`, `:edit`, and every interactive action render in a slideover panel. Switch the chrome and width from the definition:
166
166
 
167
167
  ```ruby
168
168
  class PostDefinition < ResourceDefinition
169
- modal :slideover # default — slide-in from the right
170
- # modal :centered # centered dialog
171
- # modal false # full standalone page
169
+ modal :slideover # default — slide-in from the right
170
+ # modal :centered # centered dialog
171
+ # modal :centered, size: :lg # centered, wider container
172
+ # modal false # full standalone page
172
173
  end
173
174
  ```
174
175
 
175
- See [Reference › Resource › Actions](/reference/resource/actions) for per-action `modal:` options on interactive actions.
176
+ `size:` accepts `:sm`, `:md` (default), `:lg`, `:xl`, `:auto` (hugs content), or `:full`. See [Reference › Resource › Actions](/reference/resource/actions) for per-action `modal:` / `size:` overrides on interactive actions.
176
177
 
177
178
  ## Layouts and the shell
178
179
 
@@ -8,7 +8,7 @@ Each tenant sees only their own records. Queries are filtered, forms inject the
8
8
 
9
9
  ## 🚨 Critical
10
10
 
11
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super` to a parent policy (e.g., a package base) that calls it.
11
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
12
12
  - **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
13
13
  - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
14
14
 
@@ -28,7 +28,7 @@ rails g pu:saas:setup --user Customer --entity Organization
28
28
 
29
29
  This **meta-generator** creates the user + entity + membership trio AND runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install` in one shot. The portal is fully wired for entity scoping.
30
30
 
31
- See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup-pu-saas-setup).
31
+ See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
32
32
 
33
33
  ## Manual setup
34
34
 
@@ -69,7 +69,7 @@ Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this auto
69
69
  mount CustomerPortal::Engine, at: "/customer"
70
70
  ```
71
71
 
72
- URLs now include the entity id: `/customer/organizations/42/posts`.
72
+ URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models — `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
73
73
 
74
74
  ### 5. Compound uniqueness
75
75
 
@@ -88,14 +88,14 @@ end
88
88
 
89
89
  ```ruby
90
90
  scope_to_entity Organization, strategy: :path
91
- # → /organizations/:organization_id/posts
91
+ # → /<mount>/:organization_scoped/posts (request URL: /<mount>/42/posts)
92
92
  ```
93
93
 
94
94
  ### Custom param key
95
95
 
96
96
  ```ruby
97
97
  scope_to_entity Organization, strategy: :path, param_key: :org_id
98
- # → /orgs/:org_id/posts
98
+ # → /<mount>/:org_id/posts (same URL shape, just renames params[:organization_scoped] → params[:org_id])
99
99
  ```
100
100
 
101
101
  ### Subdomain / session / custom
@@ -187,7 +187,7 @@ relation_scope do |relation|
187
187
  end
188
188
  ```
189
189
 
190
- 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise the runtime verification raises. Calling it explicitly here is safest; `super` works only if the parent policy also calls it.
190
+ 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
191
191
 
192
192
  ## Cross-tenant operations — super-admin portal
193
193
 
@@ -148,7 +148,7 @@ Pre-styled ready-to-use components:
148
148
  | Tables | `.pu-table-wrapper`, `.pu-table`, `-header`, `-header-cell`, `-body-row`, `-body-row-selected`, `-body-cell`, `.pu-selection-cell` |
149
149
  | Toolbars / empty states | `.pu-toolbar`, `-text`, `-actions`; `.pu-empty-state`, `-icon`, `-title`, `-description` |
150
150
 
151
- Full catalog: [Reference › UI › Assets › Component classes](/reference/ui/assets#component-classes-pu-).
151
+ Full catalog: [Reference › UI › Assets › Component classes](/reference/ui/assets#component-classes-pu).
152
152
 
153
153
  ## Migrating from hardcoded classes
154
154
 
@@ -89,7 +89,7 @@ EMAIL=admin@example.com rails rodauth:admin
89
89
 
90
90
  The task creates the account and triggers a verification email; the admin sets their own password via that flow. No password is passed on the command line.
91
91
 
92
- ## SaaS setup — `pu:saas:setup` (meta-generator)
92
+ ## SaaS setup — `pu:saas:setup` (meta-generator) {#saas-setup}
93
93
 
94
94
  Creates the User + Entity + Membership trio AND runs:
95
95
 
@@ -247,7 +247,7 @@ Filter which records the user can see.
247
247
 
248
248
  ### Always compose with `default_relation_scope`
249
249
 
250
- 🚨 `relation_scope` MUST call `default_relation_scope(relation)` explicitly. Never `super` — the semantics depend on how ActionPolicy's DSL registered the scope. Plutonium enforces this at runtime via `verify_default_relation_scope_applied!`.
250
+ 🚨 `relation_scope` MUST end up calling `default_relation_scope(relation)` somewhere in the chain. `super` works `Plutonium::Resource::Policy` defines a default scope block that calls `default_relation_scope`, so a subclass that does `super(relation).where(...)` is fine. Calling `default_relation_scope` explicitly is also fine (and required when you skip the parent chain). Plutonium enforces this at runtime via `verify_default_relation_scope_applied!`.
251
251
 
252
252
  ```ruby
253
253
  # ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
@@ -0,0 +1,61 @@
1
+ # Configuration
2
+
3
+ Plutonium is configured through `Plutonium.configure` in an initializer. A generated app has this at `config/initializers/plutonium.rb`:
4
+
5
+ ```ruby
6
+ # config/initializers/plutonium.rb
7
+ Plutonium.configure do |config|
8
+ config.load_defaults 1.0
9
+
10
+ # config.shell = :modern
11
+ # config.navii_host_url = "https://api.navii.dev"
12
+
13
+ config.assets.logo = "plutonium.png"
14
+ config.assets.favicon = "plutonium.ico"
15
+ config.assets.stylesheet = "plutonium.css"
16
+ config.assets.script = "plutonium.min.js"
17
+ end
18
+ ```
19
+
20
+ Access the live config anywhere via `Plutonium.configuration`.
21
+
22
+ ## Versioned defaults
23
+
24
+ ```ruby
25
+ config.load_defaults 1.0
26
+ ```
27
+
28
+ Loads the baseline defaults for a given framework version. Call this first; later versions layer their changes on top. Read the resolved version with `config.defaults_version`.
29
+
30
+ ## Options
31
+
32
+ | Option | Default | Description |
33
+ |--------|---------|-------------|
34
+ | `load_defaults(version)` | — | Apply versioned framework defaults. Call first. |
35
+ | `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. You rarely set this in an app — see [Development mode](#development-mode). |
36
+ | `cache_discovery` | `true` outside `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
37
+ | `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
38
+ | `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail) or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
39
+ | `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
40
+ | `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
41
+ | `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
42
+ | `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. |
43
+ | `assets.script` | `"plutonium.min.js"` | JavaScript entry. |
44
+
45
+ ## Development mode
46
+
47
+ `config.development?` is driven by the `PLUTONIUM_DEV` environment variable, not set in the initializer. It’s primarily for working **on the Plutonium gem** (uses local `src/` assets, enables hot reloading, and shows more detailed errors). Applications generally leave it unset.
48
+
49
+ ```bash
50
+ export PLUTONIUM_DEV=1
51
+ ```
52
+
53
+ ## Assets
54
+
55
+ Asset entries live under `config.assets` and point the framework at your compiled stylesheet/script and brand imagery. The `pu:core:assets` generator wires these up. See [Assets](./ui/assets) for the full asset/Tailwind/Stimulus setup.
56
+
57
+ ## Related
58
+
59
+ - [Assets](./ui/assets) — stylesheet, script, Tailwind, and design tokens
60
+ - [Layouts](./ui/layouts) — the `shell` option and ejecting chrome
61
+ - [Components › Avatar](./ui/components#avatar) — `navii_host_url`
@@ -70,7 +70,8 @@ action :name,
70
70
  turbo_frame: "_top",
71
71
  return_to: "/custom/path",
72
72
  route_options: {action: :foo},
73
- modal: :slideover # :centered (default) or :slideoverchrome for the action's interaction form
73
+ modal: :slideover, # :slideover / :centeredoverrides the definition's modal mode
74
+ size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides the definition's modal size
74
75
  ```
75
76
 
76
77
  ### Deriving variants — `Action#with(...)`
@@ -410,15 +410,16 @@ class PostDefinition < ResourceDefinition
410
410
  # false — always hide
411
411
  submit_and_continue false
412
412
 
413
- # How :new / :edit render
413
+ # How :new / :edit and interactive actions render
414
414
  # :slideover (default) — slide-in panel from the right
415
415
  # :centered — centered dialog
416
416
  # false — full standalone pages (no modal)
417
- modal :centered
417
+ # size: optional, one of :sm, :md (default), :lg, :xl, :auto, :full
418
+ modal :centered, size: :lg
418
419
  end
419
420
  ```
420
421
 
421
- `modal:` only affects framework `:new`/`:edit` actions. Custom interactive actions have their own per-action `modal:` option — see [Actions](./actions).
422
+ `modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win — see [Actions](./actions).
422
423
 
423
424
  ## Metadata panel (show page)
424
425
 
@@ -4,8 +4,7 @@ Multi-tenant data isolation. Built on three cooperating pieces — portal, polic
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly.
8
- - **Don't rely on `super`** inside `relation_scope` — call `default_relation_scope(relation)` by name.
7
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!` make sure the chain ends up calling `default_relation_scope(relation)` somewhere (explicitly, or via `super` to a parent that calls it — `Plutonium::Resource::Policy` does).
9
8
  - **Fix the MODEL, not the policy.** If `associated_with` can't resolve, declare an association path (`belongs_to`, `has_one :through`) OR a custom `associated_with_<entity>` scope on the model. Never paper over it with a `where` in the policy.
10
9
  - **Compound uniqueness scoped to the tenant FK** — `validates :code, uniqueness: {scope: :organization_id}`.
11
10
  - **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller.
@@ -180,8 +179,8 @@ relation_scope { |r| r.joins(:project).where(projects: {organization_id: current
180
179
  relation_scope { |r| r.where(published: true) }
181
180
  ```
182
181
 
183
- ::: danger Don't use `super`
184
- `super` inside `relation_scope` is unreliable its semantics depend on how ActionPolicy's DSL registered the scope. Call `default_relation_scope(relation)` by name.
182
+ ::: tip `super` works too
183
+ `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope(relation)`, so `super(relation)` from a subclass picks it up and the runtime check passes. Use whichever reads more clearly — `super(relation).where(archived: false)` and `default_relation_scope(relation).where(archived: false)` are equivalent when extending the framework base. Call `default_relation_scope` explicitly when you're not chaining via `super` (e.g. replacing the scope entirely).
185
184
  :::
186
185
 
187
186
  ### Intentionally skipping the scope
@@ -215,7 +214,7 @@ module CustomerPortal
215
214
  end
216
215
  ```
217
216
 
218
- Routes become `/organizations/:organization_id/posts`. The portal extracts `params[:organization_id]` and loads the entity automatically.
217
+ Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time — the entity id is the first path segment after the mount). The portal extracts `params[:organization_scoped]` and loads the entity automatically.
219
218
 
220
219
  ### Custom strategy (subdomain, session, etc.)
221
220
 
@@ -239,18 +238,19 @@ The strategy symbol must match a method name on the controller concern.
239
238
 
240
239
  ### Custom param key
241
240
 
242
- The default `param_key` derives from the entity class — `<singular_route_key>_scoped` — to avoid collisions with a `belongs_to :organization` on child models. So `scope_to_entity Organization` produces routes like `/organization_scoped/:organization_scoped_id/posts`. Override when you want a cleaner URL:
241
+ The default `param_key` derives from the entity class — `<singular_route_key>_scoped` (e.g. `:organization_scoped`) — to avoid colliding with a `belongs_to :organization` on child models when reading `params[:organization]`. The URL itself just uses the entity id as the first segment after the mount:
243
242
 
244
- ```ruby
245
- scope_to_entity Organization, strategy: :path, param_key: :org_id
246
- # → /org_id/:org_id/posts
243
+ ```
244
+ mount CustomerPortal::Engine, at: "/customer"
245
+ # → /customer/:organization_scoped/posts (route definition)
246
+ # → /customer/42/posts (request URL)
247
247
  ```
248
248
 
249
- Pair with `route_key:` to control the path segment as well:
249
+ Override when you want a different param name (e.g. for readability when reading params in custom controllers):
250
250
 
251
251
  ```ruby
252
- scope_to_entity Organization, strategy: :path, param_key: :org_id, route_key: :orgs
253
- # → /orgs/:org_id/posts
252
+ scope_to_entity Organization, strategy: :path, param_key: :org_id
253
+ # → params[:org_id] instead of params[:organization_scoped]
254
254
  ```
255
255
 
256
256
  ### Accessing the scoped entity
@@ -353,7 +353,6 @@ Without the scope, uniqueness leaks across tenants — Org A and Org B could col
353
353
  ## Gotchas
354
354
 
355
355
  - **Policy tries to filter by entity directly.** Wrong — bypasses `default_relation_scope`. Add the association path to the model instead.
356
- - **`super` inside `relation_scope`.** Unreliable. Use `default_relation_scope(relation)` explicitly.
357
356
  - **Multiple associations to the same entity class.** Override `scoped_entity_association`.
358
357
  - **`param_key` differs from association name.** Fine — Plutonium finds the association by class.
359
358
  - **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
@@ -10,6 +10,7 @@ Inside any `Plutonium::UI::Component::Base` subclass (or any page/form/display c
10
10
  PageHeader(title: "Dashboard", description: "...", actions: [...])
11
11
  Panel(class: "mt-4") { p { "Content" } }
12
12
  Block { TabList(items: tabs) }
13
+ Avatar(user)
13
14
  EmptyCard("No items found")
14
15
  ActionButton(action, url: "/posts/new")
15
16
  DynaFrameHost(src: "/some/path", loading: :lazy)
@@ -23,6 +24,58 @@ Breadcrumbs()
23
24
 
24
25
  These are shorthand for `render Plutonium::UI::PageHeader.new(...)` etc. — they work because every component class is exposed as a method on `Plutonium::UI::Component::Base`.
25
26
 
27
+ ## Avatar
28
+
29
+ `Plutonium::UI::Avatar` renders a profile image for a subject. It resolves an optional image source and falls back to a deterministic avatar from the hosted [Navii](https://navii.dev) service, then to a generic user icon when there's nothing to show.
30
+
31
+ ![Avatar — Navii fallback across sizes, deterministic faces for string subjects, explicit image src, and the icon fallback](/images/components/avatar.png)
32
+
33
+ ```ruby
34
+ Avatar(user) # Navii fallback seeded from the record
35
+ Avatar(user, src: :avatar) # user.avatar if present, else Navii fallback
36
+ Avatar(user, src: user.avatar) # pass the attachment/uploader/URL directly
37
+ Avatar("acme-team") # a String subject is a deterministic seed
38
+ Avatar("https://.../p.png") # a URL-shaped subject is shown as the image
39
+ Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
40
+ ```
41
+
42
+ | Param | Default | Notes |
43
+ |-----------|---------|-------|
44
+ | `subject` | `nil` | **Positional.** The identity the fallback is seeded from: a record (hashed to a PII-free seed) or a String. Also the default `alt`. A **URL-shaped** String (`http(s)://…` or `/…`) is treated as `src` instead, so `Avatar(photo_url)` shows the image. |
45
+ | `src:` | `nil` | The image. A **Symbol** names a method on the subject (`:avatar` → `subject.avatar`); otherwise an ActiveStorage attachment, [active_shrine](https://github.com/radioactive-labs/active_shrine)/Shrine uploader, or URL string. |
46
+ | `size:` | `:md` | Semantic `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer (px). |
47
+ | `alt:` | derived | Defaults to the String subject, or the record's display name. |
48
+ | `class:` | — | Merged over the default `rounded-full` classes. |
49
+
50
+ ### How the source resolves
51
+
52
+ `src` is resolved in this order, so the same component works across attachment libraries:
53
+
54
+ - **ActiveStorage** attachment → `helpers.url_for` (the Rails-routable redirect path)
55
+ - **active_shrine** / Shrine `UploadedFile` / CarrierWave (anything responding to `#url`) → `value.url`
56
+ - **URL string** (`"https://…"` or `"/…"`) → used as-is
57
+
58
+ When `src` is absent or unattached, a Navii avatar is rendered from the subject; with no subject either, a generic user icon is shown.
59
+
60
+ ::: tip Symbol `src` is a contract
61
+ `Avatar(user, src: :avatar)` calls `user.avatar` — the subject **must** respond to it (a `NoMethodError` is raised otherwise). Use a Symbol `src` only with a record subject, not a value that might be a plain string (e.g. a guest `current_user`).
62
+ :::
63
+
64
+ ### Privacy
65
+
66
+ The value sent to Navii is **always a hash** of the subject's identity (`Digest::SHA256` of `"Class:id"` for a record, or of the string for a String subject). No model names, IDs, emails, or seed strings ever reach the external service, and the avatar stays deterministic (same subject → same avatar).
67
+
68
+ ### Configuration
69
+
70
+ ```ruby
71
+ # config/initializers/plutonium.rb
72
+ Plutonium.configure do |config|
73
+ config.navii_host_url = "https://api.navii.dev" # default; repoint to self-host/proxy
74
+ end
75
+ ```
76
+
77
+ The component appends Navii's `/avatar/:seed` route to this host.
78
+
26
79
  ## Writing custom Phlex components
27
80
 
28
81
  ```ruby
@@ -150,7 +150,7 @@ end
150
150
  - Bare tag — just the input element. Use when you're laying out custom wrappers.
151
151
  - `wrapped(class: "...")` — pass classes to the wrapper div.
152
152
 
153
- ## Association inputs (`secure_association_tag`)
153
+ ## Association inputs (`secure_association_tag`) {#association-inputs}
154
154
 
155
155
  Association inputs render with two affordances out of the box:
156
156
 
@@ -133,17 +133,18 @@ See [Forms › Association inputs](./forms#association-inputs).
133
133
 
134
134
  ## Modals & slideovers
135
135
 
136
- The framework's `:new` / `:edit` actions render inline inside a modal. Choose the chrome per-resource via the definition:
136
+ The framework's `:new` / `:edit` actions and any interactive action render inline inside a modal. Choose the chrome (and optional width) per-resource via the definition — interactive actions inherit the same default:
137
137
 
138
138
  ```ruby
139
139
  class PostDefinition < ResourceDefinition
140
- modal :slideover # default — slide-in panel from the right
141
- # modal :centered # centered dialog
142
- # modal false # full standalone pages (no modal)
140
+ modal :slideover # default — slide-in panel from the right
141
+ # modal :centered # centered dialog
142
+ # modal :centered, size: :lg # centered, wider container
143
+ # modal false # full standalone pages (no modal)
143
144
  end
144
145
  ```
145
146
 
146
- Custom interactive actions render in their own dialog with their own per-action `modal:` option (`:centered` default, or `:slideover`). See [Resource › Actions](/reference/resource/actions#action-options).
147
+ `size:` accepts `:sm`, `:md` (default), `:lg`, `:xl`, `:auto` (hugs content width), or `:full`. Per-action `modal:` / `size:` on an interactive action overrides the definition's default. See [Resource › Actions](/reference/resource/actions#action-options).
147
148
 
148
149
  ## Tabs on the show page
149
150
 
@@ -0,0 +1,153 @@
1
+ # Avatar Component (Navii fallback) — Design
2
+
3
+ **Date:** 2026-05-29
4
+ **Status:** Approved, ready for implementation plan
5
+
6
+ ## Summary
7
+
8
+ Add a reusable `Plutonium::UI::Avatar` Phlex component that renders a profile
9
+ image from an optional attachment/uploader/URL, and falls back to a
10
+ deterministic avatar generated by the hosted [Navii](https://navii.dev) service
11
+ when no image is supplied. Adopt it in `NavUser` and `Card`.
12
+
13
+ ## Motivation
14
+
15
+ `NavUser` currently renders a supplied `avatar_url` or a generic User icon, and
16
+ `Card`'s `:image` slot resolves attachments/URLs but has no fallback. There is no
17
+ shared, reusable way to render a profile image with a sensible default. Navii
18
+ provides deterministic, zero-dependency avatars via a simple image URL, which
19
+ fits a server-rendered Phlex component cleanly.
20
+
21
+ ## Integration approach
22
+
23
+ **Hosted URL, server-rendered.** Render a plain `<img>` whose `src` points at the
24
+ Navii hosted API. No npm dependency, no Stimulus controller, no build step, fully
25
+ SSR-friendly.
26
+
27
+ Navii URL shape: `https://api.navii.dev/avatar/{seed}?size={px}` (SVG by default,
28
+ which scales cleanly inside an `<img>`).
29
+
30
+ ## Component API
31
+
32
+ New component: `lib/plutonium/ui/avatar.rb` → `Plutonium::UI::Avatar`, extending
33
+ `Plutonium::UI::Component::Base`, sitting alongside `NavUser` as a general UI
34
+ primitive. Registered in `Component::Kit` as `Avatar(...)`.
35
+
36
+ ```ruby
37
+ Avatar(user) # → Navii fallback seeded from the record
38
+ Avatar(user, src: :photo) # → user.photo if present, else Navii fallback
39
+ Avatar(user, src: user.photo) # → pass the attachment/uploader/URL directly
40
+ Avatar("acme-team") # → String subject = literal seed
41
+ Avatar(src: "https://.../p.png") # → bare image, no subject/fallback
42
+ ```
43
+
44
+ ### Parameters
45
+
46
+ | Param | Default | Notes |
47
+ |------------|---------|-------|
48
+ | `subject` | `nil` | **Positional.** The identity the fallback is seeded from: a record (hashed to a PII-free seed) or a String (used verbatim). Also the default `alt`. |
49
+ | `src:` | `nil` | The image. A Symbol names a method on the subject (`:avatar` → `subject.avatar`); otherwise an ActiveStorage attachment, active_shrine/Shrine uploader, or URL string. |
50
+ | `size:` | `:md` | Semantic `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer (px). Drives the Navii `?size=`, the `<img>` width/height, and the `w-/h-` classes. |
51
+ | `alt:` | derived | Defaults to the String subject, or `display_name_of(record)`. |
52
+ | `class:` | — | Merged over the default `rounded-full` classes (passthrough). |
53
+
54
+ The earlier `record:`/`seed:` split collapsed into the single positional
55
+ `subject` (record **or** String), and `size:` moved from raw pixels to a
56
+ semantic scale (Integer still accepted as an escape hatch).
57
+
58
+ ## Resolution + seed logic
59
+
60
+ 1. **Resolve `src`** using the logic currently in `card.rb`'s `image_src_for`:
61
+ - ActiveStorage (`responds_to? :attached?`) → `helpers.url_for(value)` when attached, else `nil`
62
+ - Uploader (`responds_to? :url`) → `value.url`
63
+ - String starting with `http` or `/` → passthrough
64
+ - rescue `ArgumentError`/`URI::InvalidURIError` → `nil`
65
+
66
+ This method is **extracted into the Avatar component** (or a shared resolver
67
+ module) and `Card` delegates to it, removing the current duplication.
68
+
69
+ 2. If `src` resolves to a URL → render it.
70
+
71
+ 3. Otherwise build a Navii URL from the seed.
72
+
73
+ ### Seed derivation (no PII)
74
+
75
+ The value sent to Navii is **always** a short SHA256 hash, regardless of subject
76
+ type — no plaintext ever leaves the app:
77
+
78
+ - Record `subject` → identity `"#{record.class.name}:#{record.id}"`, then hashed.
79
+ - String `subject` → the string is hashed (not sent verbatim).
80
+ - Final seed = `Digest::SHA256.hexdigest(identity)[0, 16]`.
81
+
82
+ This keeps model names, raw IDs, emails, and any caller-provided seed string out
83
+ of the URL that reaches the external service, while preserving determinism (same
84
+ identity → same hash → same avatar).
85
+
86
+ ### Navii host configuration
87
+
88
+ Add `config.navii_host_url` to `lib/plutonium/configuration.rb`, defaulting to
89
+ `https://api.navii.dev` (host only — the component appends the `/avatar/:seed`
90
+ route, which is Navii's API shape). Lets apps self-host or repoint the service
91
+ without code changes.
92
+
93
+ ## Rendering
94
+
95
+ A single `<img>`:
96
+ - `src` = resolved image URL or Navii URL
97
+ - default classes `rounded-full object-cover`, plus the semantic `w-/h-` size
98
+ class (so Tailwind preflight's `img { height: auto }` doesn't collapse the
99
+ dimensions), with caller `class:` merged over
100
+ - `width` / `height` = pixel size; raw-Integer sizes also emit an inline
101
+ `width/height` style
102
+ - `loading: "lazy"`
103
+ - `alt:` from `alt:` or the subject
104
+
105
+ No JavaScript; fully server-rendered.
106
+
107
+ ### Last-resort guard
108
+
109
+ If there is no resolvable `src` **and** no `subject` to derive a seed from, fall
110
+ back to a generic User icon (sized to match) rather than emitting a broken Navii
111
+ URL. In all other cases a Navii avatar is rendered (the generic-icon branch in
112
+ `NavUser` is otherwise removed).
113
+
114
+ ## Integration points
115
+
116
+ ### NavUser (`lib/plutonium/ui/nav_user.rb`)
117
+
118
+ - Add a `record:` parameter, passed `current_user` from
119
+ `app/views/plutonium/_resource_header.html.erb`.
120
+ - Replace the inline `<img>` / icon-fallback branch in `render_trigger_button`
121
+ with `Avatar(record, src: avatar_url, size: :sm, ...)`.
122
+ - `avatar_url:` continues to work as an explicit override.
123
+ - The generic-User-icon fallback is removed (now handled by the component's
124
+ last-resort guard only).
125
+
126
+ ### Card (`lib/plutonium/ui/grid/card.rb`)
127
+
128
+ - The small `:image` slot renders `Avatar(src: value, size: :lg)` — **no
129
+ subject is passed**, so an image-less card falls back to Avatar's generic
130
+ icon rather than firing a per-card request to the external Navii service
131
+ (which would mean one third-party request per record in a grid). The
132
+ `:cover` banner stays a plain `<img>` (no avatar fallback).
133
+ - The `:cover` branch calls `Avatar.resolve_image_src` directly; the old
134
+ `image_src_for` helper (duplicated resolution logic) is removed.
135
+
136
+ ## Testing
137
+
138
+ Unit tests for `Avatar` (`test/plutonium/ui/avatar_test.rb`):
139
+ - `resolve_image_src` paths: nil, URL strings, uploader `#url`, ActiveStorage via
140
+ `url_for`, and active_shrine-style wrappers (attached?+url) via `#url`
141
+ - `src` precedence; Symbol `src` sent to the subject; Symbol `src` with no subject
142
+ - record seed determinism + absence of PII; String subject as literal seed (+escaping)
143
+ - semantic size → `?size=`, `w-/h-` classes, `width`/`height`; Integer size → inline style
144
+ - `alt` default vs explicit
145
+ - last-resort icon when there is neither `src` nor subject (and record without id)
146
+ - configured Navii base URL
147
+
148
+ ## Out of scope (YAGNI)
149
+
150
+ - Client-side `@usenavii` SDK / Stimulus rendering
151
+ - Self-hosted/vendored generation
152
+ - Non-circular shapes, status badges, image cropping/upload UI
153
+ - Caching / proxying Navii responses
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.51.0)
4
+ plutonium (0.52.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.51.0)
4
+ plutonium (0.52.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.51.0)
4
+ plutonium (0.52.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -44,9 +44,13 @@ module Pu
44
44
 
45
45
  Rails.application.configure do
46
46
  config.solid_errors.connects_to = {database: {writing: :#{@db_name}}}
47
- config.solid_errors.send_emails = ENV["SOLID_ERRORS_SEND_EMAILS"].present?
48
- config.solid_errors.email_from = ENV["SOLID_ERRORS_EMAIL_FROM"]
49
- config.solid_errors.email_to = ENV["SOLID_ERRORS_EMAIL_TO"]
47
+ config.solid_errors.email_from = ENV["SOLID_ERRORS_EMAIL_FROM"].presence
48
+ config.solid_errors.email_to = ENV["SOLID_ERRORS_EMAIL_TO"].presence
49
+ # Only deliver notifications when explicitly opted in AND both addresses are
50
+ # configured. Enabling send_emails without a valid from/to makes Solid Errors
51
+ # attempt to deliver malformed mail.
52
+ config.solid_errors.send_emails = ENV["SOLID_ERRORS_SEND_EMAILS"].present? &&
53
+ config.solid_errors.email_from.present? && config.solid_errors.email_to.present?
50
54
  config.solid_errors.username = ENV.fetch("SOLID_ERRORS_USERNAME", nil)
51
55
  config.solid_errors.password = ENV.fetch("SOLID_ERRORS_PASSWORD", nil)
52
56
  end