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
@@ -1,11 +1,6 @@
1
1
  module Plutonium
2
2
  module Helpers
3
3
  module DisplayHelper
4
- # def tooltip(text)
5
- # text = sanitize text
6
- # "title=\"#{text}\" data-controller=\"tooltip\" data-bs-title=\"#{text}\"".html_safe
7
- # end
8
-
9
4
  def resource_name(resource_class, count = 1)
10
5
  resource_class.model_name.human.pluralize(count)
11
6
  end
@@ -32,59 +27,10 @@ module Plutonium
32
27
  end
33
28
  end
34
29
 
35
- def display_field(value:, helper: nil, **options)
36
- return "-" unless value.present?
37
-
38
- stack_multiple = options.key?(:stack_multiple) ? options.delete(:stack_multiple) : helper != :display_name_of
39
-
40
- # clean options list
41
- options.select! { |k, _v| !k.starts_with? "pu_" }
42
-
43
- if value.respond_to?(:each) && stack_multiple
44
- tag.ul class: "list-unstyled m-0" do
45
- value.each do |val|
46
- rendered = display_field_value(value: val, helper:, **options)
47
- concat tag.li(rendered)
48
- end
49
- end
50
- else
51
- rendered = display_field_value(value:, helper:, **options)
52
- tag.span rendered
53
- end
54
- end
55
-
56
30
  def display_datetime_value(value)
57
31
  timeago value
58
32
  end
59
33
 
60
- def display_field_value(value:, helper: nil, title: nil, **)
61
- title = (title != false) ? title || display_name_of(value) : nil
62
- rendered = helper.present? ? send(helper, value, **) : value
63
- tag.span rendered, title:
64
- end
65
-
66
- def display_association_value(association)
67
- display_name = display_name_of(association)
68
- if registered_resources.include?(association.class)
69
- link_to display_name, resource_url_for(association, parent: nil),
70
- class: "font-medium text-primary-600 dark:text-primary-500"
71
- else
72
- display_name
73
- end
74
- end
75
-
76
- def display_numeric_value(value)
77
- number_with_delimiter value
78
- end
79
-
80
- def display_boolean_value(value)
81
- tag.input type: :checkbox, class: "form-check-input", checked: value, disabled: true
82
- end
83
-
84
- def display_url_value(value)
85
- link_to nil, value, class: "font-medium text-primary-600 dark:text-primary-500", target: :blank
86
- end
87
-
88
34
  def display_name_of(obj, separator: ", ")
89
35
  return unless obj.present?
90
36
 
@@ -103,14 +49,6 @@ module Plutonium
103
49
  # Oh well. Just convert it to a string.
104
50
  obj.to_s
105
51
  end
106
-
107
- def display_clamped_quill(value)
108
- clamp_content quill(value)
109
- end
110
-
111
- def display_attachment_value(value, **, &)
112
- attachment_preview(value, **, &)
113
- end
114
52
  end
115
53
  end
116
54
  end
@@ -16,8 +16,23 @@ module Plutonium
16
16
  # modal frame specifically.
17
17
  def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
18
18
 
19
- def remote_modal_frame_tag(&)
20
- turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
19
+ # Returns a turbo-frame-scoped element id. Two identically-named forms
20
+ # can be on the page simultaneously (e.g. a primary modal opens a
21
+ # secondary modal, each rendering an `id="resource-form"`). When the
22
+ # server later replies with `turbo_stream.replace("resource-form", ...)`,
23
+ # Turbo would pick the FIRST element matching the id — which is rarely
24
+ # the one the user actually submitted. Append a frame suffix so each
25
+ # frame's form has a unique id and the controller can target precisely.
26
+ #
27
+ # @param base [String, Symbol] the base id
28
+ # @return [String] the scoped id (no suffix outside any modal frame)
29
+ def turbo_scoped_dom_id(base)
30
+ base = base.to_s
31
+ case current_turbo_frame
32
+ when Plutonium::REMOTE_MODAL_FRAME then "#{base}-primary"
33
+ when Plutonium::REMOTE_MODAL_SECONDARY_FRAME then "#{base}-secondary"
34
+ else base
35
+ end
21
36
  end
22
37
  end
23
38
  end
@@ -3,10 +3,8 @@ module Plutonium
3
3
  def self.included(base)
4
4
  base.class_eval do
5
5
  include Plutonium::Helpers::ApplicationHelper
6
- include Plutonium::Helpers::AttachmentHelper
7
6
  include Plutonium::Helpers::ContentHelper
8
7
  include Plutonium::Helpers::DisplayHelper
9
- include Plutonium::Helpers::TableHelper
10
8
  include Plutonium::Helpers::TurboHelper
11
9
  include Plutonium::Helpers::TurboStreamActionsHelper
12
10
  include Plutonium::Helpers::AssetsHelper
@@ -53,7 +53,7 @@ module Plutonium
53
53
 
54
54
  respond_to do |format|
55
55
  if params[:pre_submit]
56
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))) }
56
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))) }
57
57
  format.html { render :new, status: :unprocessable_content }
58
58
  elsif resource_record!.save
59
59
  format.turbo_stream do
@@ -71,7 +71,7 @@ module Plutonium
71
71
  location: redirect_url_after_submit
72
72
  end
73
73
  else
74
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))), status: :unprocessable_content }
74
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))), status: :unprocessable_content }
75
75
  format.html { render :new, status: :unprocessable_content }
76
76
  format.any do
77
77
  @errors = resource_record!.errors
@@ -100,7 +100,7 @@ module Plutonium
100
100
 
101
101
  respond_to do |format|
102
102
  if params[:pre_submit]
103
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))) }
103
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))) }
104
104
  format.html { render :edit, status: :unprocessable_content }
105
105
  elsif resource_record!.save
106
106
  format.turbo_stream do
@@ -116,7 +116,7 @@ module Plutonium
116
116
  render :show, status: :ok, location: redirect_url_after_submit
117
117
  end
118
118
  else
119
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))), status: :unprocessable_content }
119
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))), status: :unprocessable_content }
120
120
  format.html { render :edit, status: :unprocessable_content }
121
121
  format.any do
122
122
  @errors = resource_record!.errors
@@ -38,7 +38,7 @@ module Plutonium
38
38
 
39
39
  if params[:pre_submit]
40
40
  respond_to do |format|
41
- format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
41
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
42
42
  format.html { render :interactive_record_action, formats: [:html], status: :unprocessable_content }
43
43
  end
44
44
  return
@@ -87,7 +87,7 @@ module Plutonium
87
87
 
88
88
  if params[:pre_submit]
89
89
  respond_to do |format|
90
- format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
90
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
91
91
  format.html { render :interactive_resource_action, status: :unprocessable_content }
92
92
  end
93
93
  return
@@ -134,7 +134,7 @@ module Plutonium
134
134
 
135
135
  if params[:pre_submit]
136
136
  respond_to do |format|
137
- format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
137
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
138
138
  format.html { render :interactive_bulk_action, formats: [:html], status: :unprocessable_content }
139
139
  end
140
140
  return
@@ -1,48 +1,6 @@
1
1
  module Plutonium
2
2
  module Resource
3
3
  class Definition < Plutonium::Definition::Base
4
- class_attribute :modal_mode, default: :slideover, instance_accessor: false
5
-
6
- VALID_MODAL_MODES = [:centered, :slideover, false].freeze
7
-
8
- # Sets how :new / :edit actions render.
9
- # - :slideover (default) — slide-in panel from the right
10
- # - :centered — centered dialog
11
- # - false — no modal; new/edit are full standalone pages
12
- def self.modal(mode)
13
- unless VALID_MODAL_MODES.include?(mode)
14
- raise ArgumentError, "modal must be one of #{VALID_MODAL_MODES.inspect}, got #{mode.inspect}"
15
- end
16
- self.modal_mode = mode
17
- configure_crud_modal_targets!
18
- end
19
-
20
- # Re-derives the default :new / :edit actions so their turbo_frame
21
- # matches the current `modal_mode`. Called when `.modal` is set
22
- # and once at Resource::Definition load (so the default
23
- # :slideover state propagates to the action records). Subclasses
24
- # inherit those records via DefineableProps#inherited (deep_dup);
25
- # calling `.modal` on a subclass re-runs this method locally.
26
- def self.configure_crud_modal_targets!
27
- target = (modal_mode == false) ? nil : Plutonium::REMOTE_MODAL_FRAME
28
- [:new, :edit].each do |name|
29
- action = defined_actions[name]
30
- next unless action
31
- next if action.turbo_frame == target
32
- defined_actions[name] = action.with(turbo_frame: target)
33
- end
34
- end
35
-
36
- def modal
37
- self.class.modal_mode
38
- end
39
-
40
- # Apply the default modal target ("remote_modal") to :new / :edit
41
- # so resources that never call `.modal` still get the slideover
42
- # behavior. Subclasses inherit the configured actions via
43
- # DefineableProps' deep_dup; calling `.modal` on a subclass
44
- # re-runs the configuration locally.
45
- configure_crud_modal_targets!
46
4
  end
47
5
  end
48
6
  end
@@ -50,7 +50,7 @@ module Plutonium
50
50
  link_to(
51
51
  url_with_return_to,
52
52
  class: button_classes,
53
- data: {turbo_frame: @action.turbo_frame}.merge(@extra_data)
53
+ data: {turbo_frame: @action.turbo_frame(current_definition)}.merge(@extra_data)
54
54
  ) do
55
55
  render_button_content
56
56
  end
@@ -67,7 +67,7 @@ module Plutonium
67
67
  data: {
68
68
  turbo: @action.turbo,
69
69
  turbo_confirm: @action.confirmation.presence,
70
- turbo_frame: @action.turbo_frame
70
+ turbo_frame: @action.turbo_frame(current_definition)
71
71
  }
72
72
  }
73
73
  ) do
@@ -84,7 +84,8 @@ module Plutonium
84
84
  }
85
85
 
86
86
  # Add turbo frame if specified
87
- link_attrs[:data] = {turbo_frame: @action.turbo_frame} if @action.turbo_frame
87
+ frame = @action.turbo_frame(current_definition)
88
+ link_attrs[:data] = {turbo_frame: frame} if frame
88
89
 
89
90
  # Add confirmation and method for non-GET requests
90
91
  if @action.confirmation || @action.route_options.method != :get
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "cgi"
5
+
6
+ module Plutonium
7
+ module UI
8
+ # Renders a profile/avatar image for a subject.
9
+ #
10
+ # Avatar(user) # Navii fallback seeded from the record
11
+ # Avatar(user, src: :photo) # user.photo if present, else Navii fallback
12
+ # Avatar(user, src: user.photo) # pass the attachment/uploader/URL directly
13
+ # Avatar("acme-team") # a String subject is a deterministic seed
14
+ # Avatar("https://.../p.png") # a URL-shaped subject is shown as the image
15
+ # Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
16
+ #
17
+ # The positional +subject+ is the identity the fallback is derived from: a
18
+ # record or a String, hashed to an opaque, PII-free seed. As a convenience, a
19
+ # URL-shaped String subject is treated as +src+ (the image) instead.
20
+ # +src+ is the image to show and may be:
21
+ # - a Symbol naming a method on the subject (e.g. +:avatar+ -> +subject.avatar+).
22
+ # This is a contract: the subject must respond to it (raises NoMethodError
23
+ # otherwise), so only use a Symbol +src+ with a record subject.
24
+ # - an ActiveStorage attachment, active_shrine/Shrine uploader, or URL String
25
+ #
26
+ # Resolution order: the resolved +src+, then a Navii avatar seeded from the
27
+ # subject, then a generic user icon when there is nothing to show.
28
+ class Avatar < Plutonium::UI::Component::Base
29
+ # Pixel dimensions per semantic size, plus the matching Tailwind width/height
30
+ # utilities (needed because the preflight resets `img { height: auto }`, so
31
+ # width/height attributes alone don't pin the rendered size).
32
+ SIZES = {xs: 24, sm: 32, md: 40, lg: 48, xl: 64}.freeze
33
+ SIZE_CLASSES = {xs: "w-6 h-6", sm: "w-8 h-8", md: "w-10 h-10", lg: "w-12 h-12", xl: "w-16 h-16"}.freeze
34
+
35
+ # Resolve an image value to a URL string. Supports:
36
+ # - ActiveStorage attachments -> helpers.url_for (they aren't routable via #url)
37
+ # - active_shrine / other ActiveStorage-style wrappers -> value.url
38
+ # - Bare Shrine::UploadedFile, CarrierWave, etc. (respond to :url) -> value.url
39
+ # - Plain URL strings ("https://..." or "/uploads/...")
40
+ #
41
+ # Exposed as a module method so collaborators (e.g. Grid::Card) can reuse
42
+ # the resolution without instantiating the component.
43
+ def self.resolve_image_src(value, helpers = nil)
44
+ return nil if value.nil?
45
+
46
+ # ActiveStorage is the only supported source that must go through Rails
47
+ # routing rather than its own #url. It has to be matched *before* the
48
+ # generic attached?/url checks, because ActiveStorage-compatible wrappers
49
+ # (e.g. active_shrine) respond to BOTH attached? and url, and those should
50
+ # resolve via their own #url instead.
51
+ if active_storage_attachment?(value)
52
+ return value.attached? ? helpers&.url_for(value) : nil
53
+ end
54
+
55
+ if value.respond_to?(:attached?) # active_shrine & other AS-style wrappers
56
+ value.attached? ? value.url : nil
57
+ elsif value.respond_to?(:url) # bare Shrine::UploadedFile, CarrierWave, ...
58
+ value.url
59
+ elsif value.is_a?(String) && value.start_with?("http", "/")
60
+ value
61
+ end
62
+ rescue ArgumentError, URI::InvalidURIError
63
+ nil
64
+ end
65
+
66
+ def self.active_storage_attachment?(value)
67
+ defined?(ActiveStorage::Attached) && value.is_a?(ActiveStorage::Attached)
68
+ end
69
+ private_class_method :active_storage_attachment?
70
+
71
+ def initialize(subject = nil, src: nil, size: :md, alt: nil, **attributes)
72
+ # A URL-shaped positional subject is really an image, not an identity:
73
+ # route it to src so Avatar("https://…/p.png") shows the image rather
74
+ # than hashing the URL into a seed.
75
+ if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/")
76
+ src = subject
77
+ subject = nil
78
+ end
79
+
80
+ @subject = subject
81
+ @src = src
82
+ @size = size
83
+ @alt = alt
84
+ @attributes = attributes
85
+ end
86
+
87
+ def view_template
88
+ url = resolved_src || navii_url
89
+
90
+ if url
91
+ img(
92
+ src: url, alt: alt_text.to_s, width: pixel_size, height: pixel_size, loading: "lazy",
93
+ **sized_attributes("rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0")
94
+ )
95
+ else
96
+ div(**sized_attributes("rounded-full bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] flex items-center justify-center shrink-0")) do
97
+ render Phlex::TablerIcons::User.new(class: "w-2/3 h-2/3")
98
+ end
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # Merge the component's base classes, the size class, and the caller's class;
105
+ # add an inline dimension style for raw-pixel (Integer) sizes.
106
+ def sized_attributes(base)
107
+ attrs = @attributes.dup
108
+ attrs[:class] = tokens(base, size_class, attrs.delete(:class))
109
+ attrs[:style] = [size_style, attrs[:style]].compact.join("; ") if size_style
110
+ attrs
111
+ end
112
+
113
+ def pixel_size
114
+ @size.is_a?(Symbol) ? SIZES.fetch(@size) : @size
115
+ end
116
+
117
+ def size_class
118
+ @size.is_a?(Symbol) ? SIZE_CLASSES.fetch(@size) : nil
119
+ end
120
+
121
+ def size_style
122
+ "width: #{@size}px; height: #{@size}px" unless @size.is_a?(Symbol)
123
+ end
124
+
125
+ def resolved_src
126
+ value = image_src_value
127
+ return nil if value.nil?
128
+
129
+ # Only reach for the Rails helper proxy when we have an attachment-style
130
+ # source (ActiveStorage needs helpers.url_for; the resolver ignores it
131
+ # for active_shrine and other #url-bearing sources).
132
+ resolver_helpers = value.respond_to?(:attached?) ? helpers : nil
133
+ self.class.resolve_image_src(value, resolver_helpers)
134
+ end
135
+
136
+ # A Symbol src names a method on the subject (e.g. :avatar -> subject.avatar);
137
+ # anything else is the attachment/uploader/URL itself.
138
+ def image_src_value
139
+ @src.is_a?(Symbol) ? @subject&.public_send(@src) : @src
140
+ end
141
+
142
+ def navii_url
143
+ seed = navii_seed
144
+ return nil unless seed
145
+
146
+ host = Plutonium.configuration.navii_host_url
147
+ "#{host}/avatar/#{CGI.escape(seed)}?size=#{pixel_size}"
148
+ end
149
+
150
+ # The value sent to Navii is ALWAYS a hash of the subject's identity, so no
151
+ # plaintext (model names, ids, emails, or caller-provided seed strings) ever
152
+ # reaches the external service. Determinism is preserved: same identity ->
153
+ # same hash -> same avatar.
154
+ def navii_seed
155
+ identity = subject_identity
156
+ return nil unless identity
157
+
158
+ Digest::SHA256.hexdigest(identity)[0, 16]
159
+ end
160
+
161
+ # Stable identity string for the subject: a String subject verbatim, or
162
+ # "Class:id" for a record. Hashed by #navii_seed before it leaves the app.
163
+ def subject_identity
164
+ case @subject
165
+ when nil then nil
166
+ when String then @subject
167
+ else "#{@subject.class.name}:#{@subject.id}" if @subject.respond_to?(:id) && @subject.id.present?
168
+ end
169
+ end
170
+
171
+ def alt_text
172
+ return @alt if @alt
173
+
174
+ case @subject
175
+ when nil then nil
176
+ when String then @subject
177
+ else helpers&.display_name_of(@subject)
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -50,6 +50,8 @@ module Plutonium
50
50
  self.class.method_defined?(build_method) || super
51
51
  end
52
52
 
53
+ def BuildAvatar(...) = Plutonium::UI::Avatar.new(...)
54
+
53
55
  def BuildBreadcrumbs(...) = Plutonium::UI::Breadcrumbs.new(...)
54
56
 
55
57
  def BuildSkeletonTable(...) = Plutonium::UI::SkeletonTable.new(...)
@@ -48,6 +48,7 @@ module Plutonium
48
48
  :in_frame?,
49
49
  :in_modal?,
50
50
  :in_secondary_modal?,
51
+ :turbo_scoped_dom_id,
51
52
  :current_interactive_action,
52
53
  :current_engine,
53
54
  :policy_for,
@@ -149,8 +149,38 @@ module Plutonium
149
149
  def initialize_attributes
150
150
  super
151
151
 
152
- attributes[:id] ||= :resource_form
153
- attributes["data-controller"] = "form"
152
+ # Only fall back to :resource_form when the caller didn't already
153
+ # name the form. Phlexi moves an explicit `attributes[:id]` onto
154
+ # `@dom_id` before this runs, so a blind `||=` here would clobber
155
+ # things like the filter slideover's `id: "filter-form"` —
156
+ # producing two `<form id="resource-form">` on the page and
157
+ # silently breaking the modal pre_submit re-render (Turbo's
158
+ # `getElementById` finds the filter form first).
159
+ attributes[:id] ||= "resource-form" if @dom_id.nil?
160
+ attributes["data-controller"] = form_data_controller
161
+ end
162
+
163
+ # `dirty-form-guard` is attached unconditionally — it self-disables
164
+ # outside a <dialog>. Branching on `in_modal?` here would fail:
165
+ # Phlex forbids view-context access before rendering begins.
166
+ def form_data_controller
167
+ "form dirty-form-guard"
168
+ end
169
+
170
+ # Scope the form id to the current turbo frame at render time (we
171
+ # can't do this in `initialize_attributes` — Phlex hasn't started
172
+ # rendering yet, so `view_context` and the request headers aren't
173
+ # accessible). Primary and secondary modals can each host a form
174
+ # without colliding on document-level turbo-stream `replace target=`
175
+ # lookups. See Helpers::TurboHelper#turbo_scoped_dom_id.
176
+ #
177
+ # Also force-replace the id (Phlexi's `mix` would otherwise prepend
178
+ # `@namespace.dom_id`, producing space-separated ids like
179
+ # "q filter-form" which break document.getElementById lookups).
180
+ def form_attributes
181
+ attrs = super
182
+ attrs[:id] = turbo_scoped_dom_id(attributes[:id]) if attributes[:id]
183
+ attrs
154
184
  end
155
185
  end
156
186
  end
@@ -24,15 +24,20 @@ module Plutonium
24
24
  def render_add_button
25
25
  return if @add_action == false
26
26
 
27
+ # Two stacking levels are supported (primary + secondary modal).
28
+ # Hide the "+" entirely once we're already inside the secondary —
29
+ # there's no tertiary frame to escalate to.
30
+ return if in_secondary_modal?
31
+
27
32
  url, turbo_frame = add_url_and_frame
28
33
  return unless url
29
34
 
30
- # When the parent form is already inside a modal, route the
31
- # "+" to the secondary frame so the stacked dialog opens on
32
- # top of the original form rather than replacing it. The
33
- # crud controller mirrors this on success — closing the
34
- # secondary modal and reloading the primary so the
35
- # association select picks up the new record.
35
+ # When the parent form is already inside the primary modal,
36
+ # route the "+" to the secondary frame so the stacked dialog
37
+ # opens on top of the original form rather than replacing it.
38
+ # The crud controller mirrors this on success — closing the
39
+ # secondary modal and reloading the primary so the association
40
+ # select picks up the new record.
36
41
  if turbo_frame == Plutonium::REMOTE_MODAL_FRAME && in_modal?
37
42
  turbo_frame = Plutonium::REMOTE_MODAL_SECONDARY_FRAME
38
43
  end
@@ -65,12 +70,13 @@ module Plutonium
65
70
  end
66
71
 
67
72
  return unless registered_resources.include?(klass)
68
- action = resource_definition(klass).defined_actions[:new]
73
+ target_definition = resource_definition(klass)
74
+ action = target_definition.defined_actions[:new]
69
75
  return unless action
70
76
  return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
71
77
 
72
78
  url = route_options_to_url(action.route_options, klass)
73
- [with_return_to(url), action.turbo_frame]
79
+ [with_return_to(url), action.turbo_frame(target_definition)]
74
80
  end
75
81
 
76
82
  def with_return_to(url)
@@ -47,7 +47,7 @@ module Plutonium
47
47
 
48
48
  def initialize_attributes
49
49
  super
50
- attributes[:id] = :interaction_form
50
+ attributes[:id] = "interaction-form"
51
51
  attributes.fetch(:data_turbo) { attributes[:data_turbo] = object.turbo.to_s }
52
52
  end
53
53
 
@@ -31,12 +31,70 @@ module Plutonium
31
31
  render_actions
32
32
  end
33
33
 
34
+ # Mirrors Phlexi::Form::Base#view_template (phlexi-form ~> 0.14)
35
+ # — keep these in sync if upgrading. We override so the guard
36
+ # dialog renders inside the <form> tag (where the JS controller
37
+ # looks for it via `dirty-form-guard-target`) even when a
38
+ # subclass overrides `form_template`. Without this, the
39
+ # controller silently falls back to `window.confirm`.
40
+ def view_template(&block)
41
+ captured_body = capture { form_template(&block) }
42
+ captured_guard = capture { render_dirty_form_guard_dialog if in_modal? }
43
+ form_tag do
44
+ form_errors
45
+ raw(safe(captured_body))
46
+ raw(safe(captured_guard))
47
+ end
48
+ end
49
+
34
50
  def form_class
35
51
  in_modal? ? "flex-1 flex flex-col min-h-0" : super
36
52
  end
37
53
 
38
54
  private
39
55
 
56
+ # Nested inside the form so showModal() stacks it in the browser's
57
+ # top layer above the surrounding slideover/centered modal — no
58
+ # z-index juggling required.
59
+ def render_dirty_form_guard_dialog
60
+ dialog(
61
+ class:
62
+ "pu-dialog " \
63
+ "top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
64
+ "w-full max-w-md p-0 " \
65
+ "open:flex flex-col " \
66
+ "opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
67
+ "transition-[opacity,transform] duration-200 ease-out",
68
+ data: {"dirty-form-guard-target": "confirmDialog"},
69
+ # Modern Chrome refuses user-agent close requests (Esc, backdrop);
70
+ # older browsers fall back to the JS controller's interception.
71
+ closedby: "none",
72
+ "aria-labelledby": "pu-dirty-guard-title",
73
+ "aria-describedby": "pu-dirty-guard-desc"
74
+ ) do
75
+ div(class: "px-6 pt-5 pb-4 border-b border-[var(--pu-border)]") do
76
+ h2(id: "pu-dirty-guard-title", class: "text-lg font-semibold text-[var(--pu-text)]") do
77
+ "Discard changes?"
78
+ end
79
+ p(id: "pu-dirty-guard-desc", class: "mt-1 text-sm text-[var(--pu-text-muted)]") do
80
+ "You have unsaved changes. Closing this form now will lose them."
81
+ end
82
+ end
83
+ div(class: "flex items-center justify-end gap-2 px-6 py-4") do
84
+ button(
85
+ type: "button",
86
+ class: "pu-btn pu-btn-md pu-btn-outline",
87
+ data: {action: "dirty-form-guard#keepEditing"}
88
+ ) { "Keep editing" }
89
+ button(
90
+ type: "button",
91
+ class: "pu-btn pu-btn-md pu-btn-danger",
92
+ data: {action: "dirty-form-guard#discard"}
93
+ ) { "Discard changes" }
94
+ end
95
+ end
96
+ end
97
+
40
98
  def render_fields
41
99
  fields_wrapper {
42
100
  resource_fields.each { |name|
@@ -14,7 +14,7 @@ module Plutonium
14
14
  inner_wrapper: "w-full",
15
15
 
16
16
  # Form errors
17
- form_errors_wrapper: "flex items-start gap-3 p-4 mb-6 text-base text-danger-800 rounded-[var(--pu-radius-lg)] bg-danger-50 border border-danger-200 dark:bg-danger-950/30 dark:border-danger-800 dark:text-danger-300",
17
+ form_errors_wrapper: "flex items-start gap-3 m-4 p-4 text-base text-danger-800 rounded-[var(--pu-radius-lg)] bg-danger-50 border border-danger-200 dark:bg-danger-950/30 dark:border-danger-800 dark:text-danger-300",
18
18
  form_errors_message: "font-semibold",
19
19
  form_errors_list: "mt-2 list-disc list-inside text-sm",
20
20
 
@@ -30,11 +30,15 @@ module Plutonium
30
30
  valid_input: "pu-input pu-input-valid",
31
31
  neutral_input: "",
32
32
 
33
- # Checkbox
33
+ # Checkbox / Boolean
34
34
  checkbox: "pu-checkbox",
35
+ boolean: "pu-checkbox",
36
+ valid_boolean: "pu-checkbox",
37
+ invalid_boolean: "pu-checkbox pu-input-invalid",
35
38
 
36
39
  # Radio buttons
37
- radio_button: "pu-checkbox",
40
+ radio_button: "pu-radio",
41
+ collection_radio_buttons: "flex flex-col gap-2",
38
42
 
39
43
  # Color
40
44
  color: "pu-color-input appearance-none bg-transparent border-none cursor-pointer w-12 h-12 rounded-lg",
@@ -46,7 +50,7 @@ module Plutonium
46
50
  file: "pu-input py-2 [&::file-selector-button]:mr-4 [&::file-selector-button]:px-4 [&::file-selector-button]:py-2 [&::file-selector-button]:bg-[var(--pu-surface-alt)] [&::file-selector-button]:border-0 [&::file-selector-button]:rounded-md [&::file-selector-button]:text-sm [&::file-selector-button]:font-semibold [&::file-selector-button]:text-[var(--pu-text-muted)] [&::file-selector-button]:hover:bg-[var(--pu-border)] [&::file-selector-button]:cursor-pointer [&::file-selector-button]:transition-colors",
47
51
 
48
52
  # Hint themes
49
- hint: "pu-hint whitespace-pre",
53
+ hint: "pu-hint whitespace-pre-wrap",
50
54
 
51
55
  # Error themes
52
56
  error: "text-xs text-danger-600 mt-1",