plutonium 0.51.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-app/SKILL.md +2 -0
  3. data/.claude/skills/plutonium-auth/SKILL.md +6 -4
  4. data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
  5. data/.claude/skills/plutonium-resource/SKILL.md +6 -4
  6. data/.claude/skills/plutonium-tenancy/SKILL.md +31 -7
  7. data/.claude/skills/plutonium-testing/SKILL.md +3 -1
  8. data/.claude/skills/plutonium-ui/SKILL.md +32 -8
  9. data/CHANGELOG.md +33 -0
  10. data/app/assets/plutonium.css +1 -1
  11. data/app/assets/plutonium.js +258 -11
  12. data/app/assets/plutonium.js.map +4 -4
  13. data/app/assets/plutonium.min.js +39 -39
  14. data/app/assets/plutonium.min.js.map +4 -4
  15. data/app/views/plutonium/_resource_header.html.erb +2 -1
  16. data/docs/.vitepress/config.ts +2 -2
  17. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  18. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  19. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  20. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  21. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  22. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  23. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  24. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  25. data/docs/.vitepress/theme/custom.css +144 -0
  26. data/docs/.vitepress/theme/index.ts +58 -1
  27. data/docs/getting-started/index.md +33 -50
  28. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  29. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  30. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  31. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  32. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  33. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  34. data/docs/guides/authentication.md +11 -6
  35. data/docs/guides/authorization.md +3 -3
  36. data/docs/guides/creating-packages.md +8 -11
  37. data/docs/guides/custom-actions.md +8 -2
  38. data/docs/guides/customizing-ui.md +259 -0
  39. data/docs/guides/index.md +49 -32
  40. data/docs/guides/multi-tenancy.md +14 -6
  41. data/docs/guides/nested-resources.md +69 -0
  42. data/docs/guides/search-filtering.md +6 -0
  43. data/docs/guides/testing.md +5 -1
  44. data/docs/guides/theming.md +14 -1
  45. data/docs/guides/user-invites.md +10 -4
  46. data/docs/guides/user-profile.md +8 -0
  47. data/docs/index.md +10 -219
  48. data/docs/public/asciinema/home-scaffold.cast +305 -0
  49. data/docs/public/images/components/avatar.png +0 -0
  50. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  51. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  52. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  53. data/docs/public/images/guides/nested-inputs.png +0 -0
  54. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  55. data/docs/public/images/guides/search-filtering-index.png +0 -0
  56. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  57. data/docs/public/images/guides/theming-after.png +0 -0
  58. data/docs/public/images/guides/theming-before.png +0 -0
  59. data/docs/public/images/guides/user-invites-landing.png +0 -0
  60. data/docs/public/images/guides/user-profile-edit.png +0 -0
  61. data/docs/public/images/guides/user-profile-show.png +0 -0
  62. data/docs/public/images/home-index.png +0 -0
  63. data/docs/public/images/home-new.png +0 -0
  64. data/docs/public/images/home-show.png +0 -0
  65. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  66. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  67. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  68. data/docs/public/images/tutorial/02-new-form.png +0 -0
  69. data/docs/public/images/tutorial/03-create-account.png +0 -0
  70. data/docs/public/images/tutorial/03-login.png +0 -0
  71. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  72. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  73. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  74. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  75. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  76. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  77. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  78. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  79. data/docs/reference/app/generators.md +4 -4
  80. data/docs/reference/auth/accounts.md +7 -8
  81. data/docs/reference/auth/index.md +1 -1
  82. data/docs/reference/behavior/policies.md +2 -2
  83. data/docs/reference/configuration.md +61 -0
  84. data/docs/reference/index.md +67 -55
  85. data/docs/reference/resource/actions.md +2 -1
  86. data/docs/reference/resource/definition.md +5 -4
  87. data/docs/reference/tenancy/entity-scoping.md +14 -8
  88. data/docs/reference/tenancy/index.md +1 -1
  89. data/docs/reference/tenancy/invites.md +12 -5
  90. data/docs/reference/ui/components.md +53 -0
  91. data/docs/reference/ui/forms.md +1 -1
  92. data/docs/reference/ui/pages.md +6 -5
  93. data/docs/reference/ui/tables.md +8 -4
  94. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  95. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  96. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  97. data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
  98. data/gemfiles/rails_7.gemfile.lock +1 -1
  99. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  100. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  101. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  102. data/lib/generators/pu/invites/install_generator.rb +44 -0
  103. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  104. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
  105. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  106. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  107. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  108. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  109. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  110. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  111. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  112. data/lib/generators/pu/saas/membership/USAGE +4 -1
  113. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  114. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  115. data/lib/plutonium/action/base.rb +43 -63
  116. data/lib/plutonium/configuration.rb +7 -0
  117. data/lib/plutonium/definition/actions.rb +10 -11
  118. data/lib/plutonium/definition/base.rb +29 -0
  119. data/lib/plutonium/helpers/assets_helper.rb +0 -30
  120. data/lib/plutonium/helpers/content_helper.rb +0 -44
  121. data/lib/plutonium/helpers/display_helper.rb +0 -62
  122. data/lib/plutonium/helpers/turbo_helper.rb +17 -2
  123. data/lib/plutonium/helpers.rb +0 -2
  124. data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
  125. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  126. data/lib/plutonium/resource/definition.rb +0 -42
  127. data/lib/plutonium/ui/action_button.rb +4 -3
  128. data/lib/plutonium/ui/avatar.rb +182 -0
  129. data/lib/plutonium/ui/component/kit.rb +2 -0
  130. data/lib/plutonium/ui/component/methods.rb +1 -0
  131. data/lib/plutonium/ui/form/base.rb +32 -2
  132. data/lib/plutonium/ui/form/components/secure_association.rb +14 -8
  133. data/lib/plutonium/ui/form/interaction.rb +1 -1
  134. data/lib/plutonium/ui/form/resource.rb +58 -0
  135. data/lib/plutonium/ui/form/theme.rb +8 -4
  136. data/lib/plutonium/ui/grid/card.rb +10 -26
  137. data/lib/plutonium/ui/modal/base.rb +36 -1
  138. data/lib/plutonium/ui/modal/centered.rb +24 -6
  139. data/lib/plutonium/ui/modal/slideover.rb +26 -11
  140. data/lib/plutonium/ui/nav_user.rb +3 -23
  141. data/lib/plutonium/ui/page/edit.rb +7 -4
  142. data/lib/plutonium/ui/page/interactive_action.rb +5 -3
  143. data/lib/plutonium/ui/page/new.rb +7 -4
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
  145. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  146. data/lib/plutonium/version.rb +1 -1
  147. data/package.json +4 -1
  148. data/src/css/components.css +38 -1
  149. data/src/css/slim_select.css +3 -2
  150. data/src/js/controllers/dirty_form_guard_controller.js +165 -0
  151. data/src/js/controllers/form_controller.js +5 -4
  152. data/src/js/controllers/register_controllers.js +2 -0
  153. data/src/js/controllers/remote_modal_controller.js +53 -19
  154. data/src/js/turbo/index.js +1 -0
  155. data/src/js/turbo/turbo_confirm.js +128 -0
  156. data/yarn.lock +108 -1
  157. metadata +52 -6
  158. data/lib/plutonium/helpers/attachment_helper.rb +0 -73
  159. data/lib/plutonium/helpers/table_helper.rb +0 -35
  160. /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fd905a5d5a930d6805e0df02632a59a0c5ecd9052c190d341f22264640c3425
4
- data.tar.gz: db6fbe37f645953c545c1f1fcd10ef82e0dc58cc996b47c2ef574812b0d6394b
3
+ metadata.gz: 1575a79af57aa89f49041cbdb1e31364cea08fb3d541398e095772470a3da6b8
4
+ data.tar.gz: a3d4dfebbec5836c9557c903393f7deebcfb17b94ca8d61ccb5a1dd1ffbd7aee
5
5
  SHA512:
6
- metadata.gz: d30de7e455c8798a60a92777141197507595f8a5d37b7e9d355e0530d4590369470f24a36f62554f6c2dcede93e9de8ac0dc6e732fa8f984b64b41d75525a3a2
7
- data.tar.gz: e95d855a9d9aee83d2edc4dac9d44f001389d4ef0f10cc9338f13c371e6798db4f73f67197b2d03b8699111ab8cb56ec8ed7335b34ec622c08dff9d8af336b1d
6
+ metadata.gz: 8c6954af1d7e64be809c28e06140b5660f58e94ec254fe2960c4d0b28b615f515df84d107528899d791ab3cc5bf38ac18cf623aa4c00d3a683bf7b963b96bc47
7
+ data.tar.gz: a5f6a8ea0ad4e9fafc6559aac4b488c131a6c796c58506a49a49e8088dd3408cd04641ab4ffef357b66a373f88bd6c99476eb2d1cd7cb79eaaf2c993c6322cf5
@@ -505,6 +505,8 @@ register_resource ::Post
505
505
  register_resource ::Profile, singular: true # if --singular
506
506
  ```
507
507
 
508
+ Re-running `pu:res:conn` for the same resource is **idempotent** — already-registered entries report `identical` and are not duplicated. Insertion falls back gracefully when the conventional `# register resources above` marker is missing (uses the `routes.draw do` opening), and warns clearly if it can't find any anchor.
509
+
508
510
  ### Generated controller
509
511
 
510
512
  ```ruby
@@ -45,7 +45,6 @@ rails generate pu:rodauth:account user [options]
45
45
  |---|---|
46
46
  | `--defaults` | Enables login, logout, remember, password reset |
47
47
  | `--kitchen_sink` | Enables ALL features |
48
- | `--primary` | Mark as primary account (no URL prefix) |
49
48
  | `--no-mails` | Skip mailer setup |
50
49
  | `--argon2` | Use Argon2 instead of bcrypt |
51
50
  | `--api_only` | JSON API only (no sessions) |
@@ -90,12 +89,15 @@ rails generate pu:rodauth:admin admin --extra-attributes=name:string,department:
90
89
  enum :role, super_admin: 0, admin: 1
91
90
  ```
92
91
 
93
- Rake task for direct admin creation:
92
+ Rake task for direct admin creation (namespace is `rodauth`, task name is the account name):
94
93
 
95
94
  ```bash
96
- rails rodauth_admin:create[admin@example.com,password123]
95
+ EMAIL=admin@example.com rails rodauth:admin
96
+ # (run without EMAIL to be prompted)
97
97
  ```
98
98
 
99
+ Creates the account and sends a verification email; the admin sets their own password through the flow. No password is passed on the command line.
100
+
99
101
  ### SaaS setup — `pu:saas:setup` (meta-generator)
100
102
 
101
103
  Creates the User + Entity + Membership trio AND runs:
@@ -109,7 +111,7 @@ Don't generate another entity portal after this. Pass `--force` to re-run.
109
111
 
110
112
  ```bash
111
113
  rails g pu:saas:setup --user Customer --entity Organization
112
- rails g pu:saas:setup --user Customer --entity Organization --roles=member,admin
114
+ rails g pu:saas:setup --user Customer --entity Organization --roles=admin,member
113
115
  rails g pu:saas:setup --user Customer --entity Organization --no-allow-signup
114
116
  rails g pu:saas:setup --user Customer --entity Organization \
115
117
  --user-attributes=name:string --entity-attributes=slug:string
@@ -18,7 +18,7 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
18
18
  - **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
19
19
  - **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
20
20
  - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
21
- - **`relation_scope` must compose with `default_relation_scope(relation)` explicitly** not `super`. See [[plutonium-tenancy]].
21
+ - **`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]].
22
22
  - **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
23
23
  - **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (undefined methods return `false`).
24
24
  - **Named custom routes.** When adding custom routes, always pass `as:` so `resource_url_for` can build URLs.
@@ -725,9 +725,10 @@ class PostDefinition < ResourceDefinition
725
725
  # nil (default) = auto (hidden for singular, shown for plural)
726
726
  submit_and_continue false
727
727
 
728
- # How :new / :edit render
728
+ # How :new / :edit + interactive actions render
729
729
  # :slideover (default), :centered, or false (full pages)
730
- modal :centered
730
+ # size: :sm / :md (default) / :lg / :xl / :auto / :full
731
+ modal :centered, size: :lg
731
732
 
732
733
  # Titles
733
734
  index_page_title "All Posts"
@@ -757,7 +758,7 @@ class PostDefinition < ResourceDefinition
757
758
  end
758
759
  ```
759
760
 
760
- `modal:` only affects the framework `:new` / `:edit` actions. Custom actions have their own per-action `modal:` option (default `:centered`).
761
+ `modal:` is the default for framework `:new` / `:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win.
761
762
 
762
763
  ## Metadata Panel (show page)
763
764
 
@@ -983,7 +984,8 @@ action :name,
983
984
  confirmation: "Are you sure?",
984
985
  turbo_frame: "_top",
985
986
  route_options: {action: :foo},
986
- modal: :slideover # :centered (default) or :slideover
987
+ modal: :slideover, # :slideover / :centered overrides definition's modal mode
988
+ size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
987
989
  ```
988
990
 
989
991
  `Action#with(...)` — actions are frozen value objects; clone with overrides:
@@ -15,7 +15,7 @@ Cross-references back to [[plutonium-resource]] (models, definitions) and [[plut
15
15
 
16
16
  ## 🚨 Critical (read first)
17
17
 
18
- - **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 not `super`.
18
+ - **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)` explicitly, or via `super(relation)` (the framework base calls it).
19
19
  - **Always declare an association path from model to entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, Plutonium raises. Fix the **model**, not the policy.
20
20
  - **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
21
21
  - **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
@@ -170,7 +170,12 @@ relation_scope { |r| r.joins(:project).where(projects: {organization_id: current
170
170
  relation_scope { |r| r.where(published: true) }
171
171
  ```
172
172
 
173
- **Do not use `super`** from inside `relation_scope`. Call `default_relation_scope(relation)` explicitly`super` semantics depend on how ActionPolicy's DSL registered the scope.
173
+ **`default_relation_scope(relation)` must end up being called somewhere in the chain**runtime verification just checks it was hit, not that you wrote it in this class. Both work:
174
+
175
+ - `default_relation_scope(relation).where(...)` — explicit, always safe
176
+ - `super(relation).where(...)` — `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope`, so chaining through `super` picks it up
177
+
178
+ Pick the one that reads better for the situation.
174
179
 
175
180
  ### Intentionally skipping
176
181
 
@@ -199,7 +204,7 @@ module AdminPortal
199
204
  end
200
205
  ```
201
206
 
202
- Routes become `/organizations/:organization_id/posts`. Portal extracts `params[:organization_id]` and loads the entity automatically.
207
+ 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). Portal extracts `params[:organization_scoped]` and loads the entity automatically. The `_scoped` suffix on the param name avoids colliding with `params[:organization]` from a `belongs_to :organization` on child models.
203
208
 
204
209
  ### Custom strategy (subdomain, session, etc.)
205
210
 
@@ -235,6 +240,7 @@ entity_scope
235
240
 
236
241
  - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one (`def scoped_entity_association = :home_team`).
237
242
  - **`param_key` differs from association name.** Fine — Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
243
+ - **Default `param_key` includes `_scoped` suffix.** `scope_to_entity Organization` reads `params[:organization_scoped]` (not `params[:organization]`) so it doesn't collide with `params[:organization]` from a `belongs_to :organization` on child models. The URL itself is unchanged — the entity id is just the first path segment after the mount (`/<mount>/42/posts`). Pass `param_key:` only if you want a different param name in your controllers.
238
244
  - **Forgetting compound uniqueness.** `validates :code, uniqueness: true` leaks across tenants. Use `uniqueness: {scope: :organization_id}`.
239
245
  - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly. Never leave a `where` bypass in code.
240
246
 
@@ -421,10 +427,18 @@ rails generate pu:invites:install
421
427
  | `--entity-model=NAME` | `Entity` | Entity model name |
422
428
  | `--user-model=NAME` | `User` | User model name |
423
429
  | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
424
- | `--membership-model=NAME` | `EntityUser` | Membership join model |
425
- | `--roles=ROLES` | `member,admin` | Comma-separated |
430
+ | `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist; roles are read from its `enum :role`) |
426
431
  | `--rodauth=NAME` | `user` | Rodauth configuration for signup |
427
432
  | `--enforce-domain` | `false` | Require invited email domain to match entity |
433
+ | `--dest=PACKAGE` | `main_app` | Package where the entity model lives (controls where `invite_user_interaction.rb` is generated) |
434
+
435
+ ::: 🚨 No `--roles` flag here
436
+ Role list is derived from the membership model's `enum :role`. Set roles via `pu:saas:membership --roles=...` (or edit the enum directly). **Index 0 is the most privileged** — typically `owner`, which the invite UI excludes from selectable choices; new invitees default to the second role (`roles[1]`).
437
+ :::
438
+
439
+ ::: 🚨 ActiveRecord encryption keys required
440
+ The invite model uses `encrypts :token, deterministic: true`. Without configured AR encryption keys, creating or accepting an invite raises `ActiveRecord::Encryption::Errors::Configuration`. The generator detects this and warns at install time — generate keys with `bin/rails db:encryption:init`, then paste the printed `active_record_encryption:` block into `config/credentials.yml.enc` (or set the equivalent `ACTIVE_RECORD_ENCRYPTION_*` ENV vars in production).
441
+ :::
428
442
 
429
443
  ### What gets created
430
444
 
@@ -619,13 +633,23 @@ class Invites::UserInvite < Invites::ResourceRecord
619
633
  end
620
634
  ```
621
635
 
622
- ### Domain enforcement / custom roles
636
+ ### Domain enforcement
623
637
 
624
638
  ```bash
625
639
  rails g pu:invites:install --enforce-domain
626
- rails g pu:invites:install --roles=viewer,editor,admin,owner
627
640
  ```
628
641
 
642
+ ### Custom roles
643
+
644
+ Set roles when generating the membership model (ordering: index 0 = most privileged):
645
+
646
+ ```bash
647
+ rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
648
+ # → enum :role, { owner: 0, admin: 1, editor: 2, viewer: 3 } (owner auto-prepended)
649
+ ```
650
+
651
+ Or edit `enum :role` on the existing membership model directly. Then run `pu:invites:install`.
652
+
629
653
  ## Portal connection
630
654
 
631
655
  ```ruby
@@ -207,7 +207,9 @@ current_account(portal: :admin)
207
207
  with_portal(:org) { ... } # scoped portal switch
208
208
  ```
209
209
 
210
- **Override hook for non-Rodauth apps:** define `sign_in_for_tests(account, portal:)` in your test class (or in `test/support/plutonium_testing.rb` for project-wide use). `AuthHelpers` will defer to it.
210
+ **Default Rodauth login expects `password: "password123"`** `login_as` POSTs to `/<account_table>/login` with that hardcoded password. Either seed test accounts with it (fixtures/factories) or override via `sign_in_for_tests` below.
211
+
212
+ **Override hook for non-Rodauth apps (or to bypass Rodauth in tests):** define `sign_in_for_tests(account, portal:)` in your test class (or in `test/support/plutonium_testing.rb` for project-wide use). `AuthHelpers` will defer to it.
211
213
 
212
214
  ```ruby
213
215
  def sign_in_for_tests(account, portal:)
@@ -351,8 +351,8 @@ end
351
351
  class PostDefinition < ResourceDefinition
352
352
  class Table < Table
353
353
  def view_template
354
- render_search_bar
355
- render_scopes_bar
354
+ render_toolbar
355
+ render_scopes_pills
356
356
 
357
357
  if collection.empty?
358
358
  render_empty_card
@@ -371,7 +371,7 @@ end
371
371
 
372
372
  | Method | Purpose |
373
373
  |---|---|
374
- | `render_search_bar`, `render_scopes_bar` | Toolbar pieces |
374
+ | `render_toolbar`, `render_scopes_pills`, `render_filter_pills`, `render_bulk_actions_toolbar` | Toolbar pieces |
375
375
  | `render_table` | Default table |
376
376
  | `render_empty_card` | Empty state |
377
377
  | `render_footer` | Pagination |
@@ -390,6 +390,7 @@ Inside any `Plutonium::UI::Component::Base` (or any page/form/display):
390
390
  PageHeader(title: "Dashboard", description: "...", actions: [...])
391
391
  Panel(class: "mt-4") { p { "Content" } }
392
392
  Block { TabList(items: tabs) }
393
+ Avatar(user) # profile image: src → Navii fallback → icon
393
394
  EmptyCard("No items found")
394
395
  ActionButton(action, url: "/posts/new")
395
396
  DynaFrameHost(src: "/some/path", loading: :lazy)
@@ -401,6 +402,28 @@ TablePagination(pagy)
401
402
  Breadcrumbs()
402
403
  ```
403
404
 
405
+ ## Avatar
406
+
407
+ `Avatar(subject = nil, src: nil, size: :md, alt: nil, **attrs)` — profile image with a deterministic [Navii](https://navii.dev) fallback. Registered in the kit.
408
+
409
+ ```ruby
410
+ Avatar(user) # Navii fallback seeded from the record
411
+ Avatar(user, src: :avatar) # user.avatar if present, else Navii fallback
412
+ Avatar(user, src: user.avatar) # pass the attachment/uploader/URL directly
413
+ Avatar("acme-team") # String subject = deterministic seed
414
+ Avatar("https://.../p.png") # URL-shaped subject is shown as the image
415
+ Avatar(src: avatar_url) # bare image, no subject/fallback
416
+ ```
417
+
418
+ - **subject** (positional): record → PII-free hashed seed + default `alt` (display name); String → seed. A URL-shaped String (`http(s)://…` or `/…`) is routed to `src` (shown as the image), not used as a seed.
419
+ - **src**: a Symbol is sent to the subject (`:avatar` → `subject.avatar`, a **contract** — raises if absent); otherwise an ActiveStorage attachment, active_shrine/Shrine uploader, or URL string. ActiveStorage resolves via `helpers.url_for`; everything else via its own `#url`.
420
+ - **size**: `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer.
421
+ - **Privacy**: the value sent to Navii is **always** a SHA256 hash — no ids, emails, or seed strings leave the app. Deterministic per subject.
422
+ - **Resolution order**: resolved `src` → Navii (from subject) → generic user icon.
423
+ - **Config**: `config.navii_host_url` (default `https://api.navii.dev`); the component appends `/avatar/:seed`.
424
+
425
+ 🚨 Ejected shells: `Avatar` only shows a Navii avatar when `NavUser` is passed `record:`. The gem's `_resource_header.html.erb` passes `record: (current_user if current_user.respond_to?(:id))`; portals that **ejected** the header before this must re-eject (`rails g pu:eject:shell --dest=<portal>`) or add the `record:` line, otherwise they keep the icon fallback. Pass a record only — a String `current_user` (e.g. a guest) would otherwise be seeded as a literal identity.
426
+
404
427
  ## Custom Phlex components
405
428
 
406
429
  ```ruby
@@ -450,17 +473,18 @@ All pages inherit this. Modals and frame navigation work without special handlin
450
473
 
451
474
  # Part 5 — Modals, Slideovers, Tabs
452
475
 
453
- ## Modal/slideover for `:new` / `:edit`
476
+ ## Modal/slideover for `:new` / `:edit` + interactive actions
454
477
 
455
478
  ```ruby
456
479
  class PostDefinition < ResourceDefinition
457
- modal :slideover # default — slide-in panel from the right
458
- # modal :centered # centered dialog
459
- # modal false # full standalone page
480
+ modal :slideover # default — slide-in panel from the right
481
+ # modal :centered # centered dialog
482
+ # modal :centered, size: :lg # centered, wider container
483
+ # modal false # full standalone page
460
484
  end
461
485
  ```
462
486
 
463
- Custom interactive actions render in their own dialog with their own per-action `modal:` option (`:centered` default, or `:slideover`). See [[plutonium-resource]] › Action Options.
487
+ Drives both framework `:new` / `:edit` and every interactive action on the definition. `size:` accepts `:sm`, `:md` (default), `:lg`, `:xl`, `:auto` (hugs content), or `:full`. Per-action `modal:` / `size:` overrides win. See [[plutonium-resource]] › Action Options.
464
488
 
465
489
  ## Tabs on the show page
466
490
 
data/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## [0.53.0] - 2026-05-31
2
+
3
+ ### 🚀 Features
4
+
5
+ - *(ui)* Add Avatar component with Navii fallback (#59)
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - *(docs)* Correct broken cross-page anchors in guides
10
+ - *(docs)* Retract incorrect "never super" guidance in relation_scope
11
+ - *(docs)* Correct scoped-URL shape in multi-tenancy docs
12
+ - *(rodauth)* Match change_password_notify mailer template name
13
+
14
+ ### 🚜 Refactor
15
+
16
+ - *(helpers)* Remove dead view helpers superseded by Phlex components
17
+ ## [0.52.0] - 2026-05-21
18
+
19
+ ### 🐛 Bug Fixes
20
+
21
+ - *(generators)* Use inclusion validation for required booleans
22
+ - *(generators/assets)* Warn on failed yarn add instead of silently continuing
23
+ - *(docs)* Let StopWriting terminals scroll horizontally instead of overflowing the column
24
+ - *(docs)* Drop misleading 15-min claim — hero CTA → 'Tutorial', getting-started title → 'Learn Plutonium by building'
25
+ - *(generators,docs)* Tutorial walkthrough + reference audit (#57)
26
+ - *(ui/form)* Scope form ids per turbo frame to prevent stream-replace collisions
27
+ - *(ui/form)* Hide secure_association "+" inside secondary modal
28
+ - *(js/form)* Dedupe pre_submit hidden field on repeat change events
29
+ - *(ui)* Form error alert margin + include model name in New/Edit page titles
30
+
31
+ ### ⚙️ Miscellaneous Tasks
32
+
33
+ - *(appraisal)* Refresh rails-8.1 gemfile.lock for v0.51.0
1
34
  ## [0.51.0] - 2026-05-14
2
35
 
3
36
  ### 🚀 Features