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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +9 -4
- data/.claude/skills/plutonium-ui/SKILL.md +29 -5
- data/CHANGELOG.md +16 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +257 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +1 -0
- data/docs/guides/authentication.md +1 -1
- data/docs/guides/custom-actions.md +2 -1
- data/docs/guides/customizing-ui.md +6 -5
- data/docs/guides/multi-tenancy.md +6 -6
- data/docs/guides/theming.md +1 -1
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/reference/auth/accounts.md +1 -1
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +4 -3
- data/docs/reference/tenancy/entity-scoping.md +12 -13
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +0 -4
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/form/base.rb +16 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +3 -2
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +7 -3
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +6 -3
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +6 -3
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- metadata +10 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /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|
|
data/docs/.vitepress/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
170
|
-
# modal :centered
|
|
171
|
-
# modal
|
|
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
|
-
|
|
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`
|
|
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
|
|
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/
|
|
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
|
-
# → /
|
|
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
|
-
# →
|
|
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.
|
|
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
|
|
data/docs/guides/theming.md
CHANGED
|
@@ -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
|
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
73
|
+
modal: :slideover, # :slideover / :centered — overrides 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
|
-
|
|
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:`
|
|
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
|
|
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
|
-
:::
|
|
184
|
-
`
|
|
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
|
|
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
|
|
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
|
-
```
|
|
245
|
-
|
|
246
|
-
# → /
|
|
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
|
-
|
|
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
|
|
253
|
-
# →
|
|
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
|
+

|
|
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
|
data/docs/reference/ui/forms.md
CHANGED
|
@@ -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
|
|
data/docs/reference/ui/pages.md
CHANGED
|
@@ -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
|
|
141
|
-
# modal :centered
|
|
142
|
-
# 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
|
-
|
|
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
|
|
@@ -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.
|
|
48
|
-
config.solid_errors.
|
|
49
|
-
|
|
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
|