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
@@ -75,20 +75,21 @@ module Plutonium
75
75
 
76
76
  def render_image_slot(size:)
77
77
  value = field_value(slots[:image])
78
- return unless value
79
- src = image_src_for(value)
80
- return unless src
81
78
 
82
79
  if size == :cover
80
+ # Cover is a full-width banner, not an avatar: only render when an
81
+ # actual image resolves (no deterministic fallback).
82
+ src = Plutonium::UI::Avatar.resolve_image_src(value, helpers)
83
+ return unless src
84
+
83
85
  div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
84
86
  img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
85
87
  end
86
88
  else
87
- img(
88
- src: src,
89
- alt: header_text.to_s,
90
- class: "w-12 h-12 rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0"
91
- )
89
+ # Small avatar slot: render the resolved image, or Avatar's generic
90
+ # icon fallback. No subject is passed, so image-less cards fall back
91
+ # to the local icon rather than a per-card request to Navii.
92
+ Avatar(src: value, size: :lg, alt: header_text.to_s)
92
93
  end
93
94
  end
94
95
 
@@ -163,7 +164,7 @@ module Plutonium
163
164
  url = route_options_to_url(show.route_options, record)
164
165
  a(
165
166
  href: url,
166
- data: {row_click_target: "show", turbo_frame: show.turbo_frame},
167
+ data: {row_click_target: "show", turbo_frame: show.turbo_frame(resource_definition)},
167
168
  class: "sr-only",
168
169
  tabindex: "-1",
169
170
  "aria-label": "Open #{header_text}"
@@ -192,23 +193,6 @@ module Plutonium
192
193
  record.public_send(name)
193
194
  end
194
195
 
195
- # Resolves a slot value to an image URL. Supports:
196
- # - ActiveStorage attachments (`record.avatar` -> Attached::One/Many)
197
- # - Shrine uploaders (`record.avatar` -> UploadedFile, responds to :url)
198
- # - Plain URL strings ("https://..." or "/uploads/...")
199
- def image_src_for(value)
200
- return nil if value.nil?
201
- if value.respond_to?(:attached?)
202
- value.attached? ? helpers.url_for(value) : nil
203
- elsif value.respond_to?(:url)
204
- value.url
205
- elsif value.is_a?(String) && value.start_with?("http", "/")
206
- value
207
- end
208
- rescue ArgumentError, URI::InvalidURIError
209
- nil
210
- end
211
-
212
196
  def row_actions
213
197
  @row_actions ||= resource_definition.defined_actions.values.select { |a|
214
198
  a.collection_record_action? && a.permitted_by?(record_policy)
@@ -9,9 +9,26 @@ module Plutonium
9
9
  slot :close
10
10
  slot :footer
11
11
 
12
- def initialize(title: nil, description: nil)
12
+ # Sizes that all modal subclasses must implement entries for in
13
+ # their SIZE_CLASSES table. `:auto` is content-driven (`w-fit`
14
+ # with a viewport cap and a sensible floor) and is the only way
15
+ # to avoid clipping forms whose natural width exceeds the
16
+ # default. Sizes intentionally mirror Tailwind's max-w-* scale
17
+ # so a definition that says `size: :xl` reads predictably.
18
+ VALID_SIZES = [:sm, :md, :lg, :xl, :auto, :full].freeze
19
+
20
+ # Resolves the concrete modal class for a definition's `modal_mode`
21
+ # symbol. Unknown / `false` modes fall back to `Slideover` so call
22
+ # sites can stay branchless.
23
+ def self.class_for_mode(mode)
24
+ (mode == :centered) ? Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
25
+ end
26
+
27
+ def initialize(title: nil, description: nil, size: :md)
13
28
  @title = title
14
29
  @description = description
30
+ @size = size
31
+ validate_size!
15
32
  end
16
33
 
17
34
  def view_template(&block)
@@ -54,9 +71,27 @@ module Plutonium
54
71
  end
55
72
 
56
73
  def dialog_classes
74
+ "#{base_dialog_classes} #{size_classes}"
75
+ end
76
+
77
+ # Positioning, backdrop, transitions — everything that does
78
+ # not vary with `size`. Width/height tokens live in
79
+ # `size_classes` so size keys can fully replace them
80
+ # (notably `:auto`, which needs `w-fit` instead of `w-full`).
81
+ def base_dialog_classes
57
82
  raise NotImplementedError
58
83
  end
59
84
 
85
+ def size_classes
86
+ self.class::SIZE_CLASSES.fetch(@size)
87
+ end
88
+
89
+ def validate_size!
90
+ return if VALID_SIZES.include?(@size)
91
+ raise ArgumentError,
92
+ "modal size must be one of #{VALID_SIZES.inspect}, got #{@size.inspect}"
93
+ end
94
+
60
95
  def inner_classes
61
96
  "flex flex-col h-full max-h-[inherit] min-h-0"
62
97
  end
@@ -4,16 +4,34 @@ module Plutonium
4
4
  module UI
5
5
  module Modal
6
6
  class Centered < Plutonium::UI::Modal::Base
7
+ # Width tokens for each VALID_SIZES key. `:md` reproduces the
8
+ # historical default (`w-full max-w-xl`); `:auto` drops `w-full`
9
+ # so the dialog hugs its content, with a floor that keeps tiny
10
+ # confirm dialogs from collapsing and a cap to stay on-screen.
11
+ SIZE_CLASSES = {
12
+ sm: "w-full max-w-md",
13
+ md: "w-full max-w-xl",
14
+ lg: "w-full max-w-2xl",
15
+ xl: "w-full max-w-4xl",
16
+ auto: "w-fit max-w-[90vw] min-w-[400px]",
17
+ full: "w-full max-w-[95vw]"
18
+ }.freeze
19
+
7
20
  protected
8
21
 
9
- def dialog_classes
10
- "rounded-[var(--pu-radius-lg)] w-full max-w-xl " \
11
- "bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
12
- "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
22
+ # Surface (bg, border, radius, backdrop) lives in `.pu-dialog` so
23
+ # the centered modal, dirty-form-guard prompt, and Turbo confirm
24
+ # can't drift on design tokens. The remaining utilities are
25
+ # positioning, sizing, and the open/close transform animation
26
+ # driven by [data-open] (set on the frame after showModal() by
27
+ # remote_modal_controller); avoids the @starting-style spec dance.
28
+ def base_dialog_classes
29
+ "pu-dialog " \
13
30
  "top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
14
31
  "max-h-[80vh] " \
15
- "hidden open:flex flex-col p-0 " \
16
- "opacity-0 open:opacity-100 transition-opacity duration-200 ease-in-out"
32
+ "open:flex flex-col p-0 " \
33
+ "opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
34
+ "transition-[opacity,transform] duration-200 ease-out"
17
35
  end
18
36
  end
19
37
  end
@@ -4,21 +4,36 @@ module Plutonium
4
4
  module UI
5
5
  module Modal
6
6
  class Slideover < Plutonium::UI::Modal::Base
7
+ # Width tokens for each VALID_SIZES key. Mobile always takes
8
+ # full width (the slideover is pinned to the right edge, so
9
+ # anything narrower than the viewport looks awkward on phones);
10
+ # the `sm:` token controls the desktop width. `:md` reproduces
11
+ # the historical default. `:auto` switches to `sm:w-auto` with
12
+ # a viewport cap so the panel grows to fit the form.
13
+ SIZE_CLASSES = {
14
+ sm: "w-full sm:w-[400px]",
15
+ md: "w-full sm:w-[480px]",
16
+ lg: "w-full sm:w-[640px]",
17
+ xl: "w-full sm:w-[800px]",
18
+ auto: "w-full sm:w-auto sm:max-w-[90vw] sm:min-w-[480px]",
19
+ full: "w-full sm:w-[95vw]"
20
+ }.freeze
21
+
7
22
  protected
8
23
 
9
- def dialog_classes
10
- "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen w-full sm:w-[480px] max-w-full max-h-screen " \
24
+ # Animation is driven by `data-open`, toggled by the remote-modal
25
+ # controller on the frame after showModal(). Mirrors the filter
26
+ # slideover's pattern — see Centered for the same rationale.
27
+ def base_dialog_classes
28
+ "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
11
29
  "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
12
- "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
30
+ "backdrop:bg-transparent data-[open]:backdrop:bg-black/60 " \
31
+ "data-[open]:backdrop:backdrop-blur-sm " \
32
+ "backdrop:transition-[background-color] backdrop:duration-300 backdrop:ease-out " \
13
33
  "rounded-none p-0 " \
14
- "hidden open:flex flex-col " \
15
- "translate-x-full open:translate-x-0 " \
16
- "transition-[transform,display,overlay] duration-300 ease-out " \
17
- "[transition-behavior:allow-discrete] " \
18
- "starting:open:translate-x-full " \
19
- "backdrop:transition-[display,overlay,background-color] backdrop:duration-300 " \
20
- "backdrop:[transition-behavior:allow-discrete] " \
21
- "starting:open:backdrop:bg-transparent"
34
+ "open:flex flex-col " \
35
+ "translate-x-full data-[open]:translate-x-0 " \
36
+ "transition-transform duration-300 ease-out"
22
37
  end
23
38
  end
24
39
  end
@@ -52,10 +52,11 @@ module Plutonium
52
52
 
53
53
  slot :section, Section, collection: true
54
54
 
55
- def initialize(email:, name: nil, avatar_url: nil)
55
+ def initialize(email:, name: nil, avatar_url: nil, record: nil)
56
56
  @email = email
57
57
  @name = name
58
58
  @avatar_url = avatar_url
59
+ @record = record
59
60
  end
60
61
 
61
62
  def view_template
@@ -68,14 +69,6 @@ module Plutonium
68
69
  private
69
70
 
70
71
  def render_trigger_button
71
- if @avatar_url.present?
72
- render_avatar_button
73
- else
74
- render_default_button
75
- end
76
- end
77
-
78
- def render_avatar_button
79
72
  button(
80
73
  type: "button",
81
74
  class: "flex mx-3 text-sm rounded-full md:mr-0 focus:ring-2 focus:ring-[var(--pu-border)] focus:ring-offset-2 transition-shadow",
@@ -84,20 +77,7 @@ module Plutonium
84
77
  data: {resource_drop_down_target: "trigger"}
85
78
  ) do
86
79
  span(class: "sr-only") { "Open user menu" }
87
- img(class: "w-8 h-8 rounded-full ring-2 ring-[var(--pu-border)]", src: @avatar_url, alt: "avatar")
88
- end
89
- end
90
-
91
- def render_default_button
92
- button(
93
- type: "button",
94
- class: "flex mx-3 p-1 text-sm border border-[var(--pu-border)] text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-full md:mr-0 focus:ring-2 focus:ring-[var(--pu-border)] focus:ring-offset-2 transition-colors",
95
- aria: {expanded: "false"},
96
- id: "user-nav-dropdown-toggle",
97
- data: {resource_drop_down_target: "trigger"}
98
- ) do
99
- span(class: "sr-only") { "Open user menu" }
100
- render Phlex::TablerIcons::User.new(class: "w-6 h-6")
80
+ Avatar(@record, src: @avatar_url, size: :sm, alt: "avatar", class: "ring-2 ring-[var(--pu-border)]")
101
81
  end
102
82
  end
103
83
 
@@ -7,7 +7,7 @@ module Plutonium
7
7
  private
8
8
 
9
9
  def page_title
10
- current_definition.edit_page_title || super || "Edit"
10
+ current_definition.edit_page_title || super || "Edit #{resource_name(resource_class, 1)}"
11
11
  end
12
12
 
13
13
  def page_description
@@ -23,9 +23,12 @@ module Plutonium
23
23
  end
24
24
 
25
25
  def render_modal_form
26
- modal_class = (current_definition.modal == :centered) ?
27
- Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
28
- render modal_class.new(title: page_title, description: page_description) do
26
+ modal_class = Plutonium::UI::Modal::Base.class_for_mode(current_definition.modal_mode)
27
+ render modal_class.new(
28
+ title: page_title,
29
+ description: page_description,
30
+ size: current_definition.modal_size
31
+ ) do
29
32
  render partial("resource_form")
30
33
  end
31
34
  end
@@ -18,12 +18,14 @@ module Plutonium
18
18
 
19
19
  def render_default_content
20
20
  if in_modal?
21
- modal_class = (current_interactive_action.modal == :slideover) ?
22
- Plutonium::UI::Modal::Slideover : Plutonium::UI::Modal::Centered
21
+ modal_class = Plutonium::UI::Modal::Base.class_for_mode(
22
+ current_interactive_action.modal_mode(current_definition)
23
+ )
23
24
 
24
25
  render modal_class.new(
25
26
  title: page_title,
26
- description: page_description
27
+ description: page_description,
28
+ size: current_interactive_action.modal_size(current_definition)
27
29
  ) do
28
30
  render partial("interactive_action_form")
29
31
  end
@@ -7,7 +7,7 @@ module Plutonium
7
7
  private
8
8
 
9
9
  def page_title
10
- current_definition.new_page_title || super || "New"
10
+ current_definition.new_page_title || super || "New #{resource_name(resource_class, 1)}"
11
11
  end
12
12
 
13
13
  def page_description
@@ -23,9 +23,12 @@ module Plutonium
23
23
  end
24
24
 
25
25
  def render_modal_form
26
- modal_class = (current_definition.modal == :centered) ?
27
- Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
28
- render modal_class.new(title: page_title, description: page_description) do
26
+ modal_class = Plutonium::UI::Modal::Base.class_for_mode(current_definition.modal_mode)
27
+ render modal_class.new(
28
+ title: page_title,
29
+ description: page_description,
30
+ size: current_definition.modal_size
31
+ ) do
29
32
  render partial("resource_form")
30
33
  end
31
34
  end
@@ -68,7 +68,7 @@ module Plutonium
68
68
  bulk_actions_target: "actionButton",
69
69
  bulk_action_name: action.name,
70
70
  bulk_action_url: url,
71
- turbo_frame: action.turbo_frame
71
+ turbo_frame: action.turbo_frame(current_definition)
72
72
  }
73
73
  ) do
74
74
  if action.icon
@@ -14,10 +14,7 @@ module Plutonium
14
14
  def initialize(*, query_object:, search_url:, search_param: :q, search_value: nil, attributes: {}, **opts, &)
15
15
  opts[:as] = :q
16
16
  opts[:method] = :get
17
- attributes = attributes.deep_merge(
18
- id: "filter-form",
19
- data: {turbo_frame: nil}
20
- )
17
+ attributes = attributes.deep_merge(data: {turbo_frame: nil})
21
18
  super(*, attributes:, **opts, &)
22
19
  @query_object = query_object
23
20
  @search_url = search_url
@@ -25,6 +22,17 @@ module Plutonium
25
22
  @search_value = search_value
26
23
  end
27
24
 
25
+ # The Filters slideover renders on every index page (off-screen
26
+ # until opened) — same DOM as any CRUD modal that might appear.
27
+ # Base defaults the form id to "resource-form"; without this
28
+ # override, document-level `turbo_stream.replace("resource-form", …)`
29
+ # from a CRUD submit would clobber the wrong form. Pick a distinct
30
+ # id so the two never collide.
31
+ def initialize_attributes
32
+ super
33
+ attributes[:id] = "filter-form"
34
+ end
35
+
28
36
  def form_class
29
37
  "flex-1 flex flex-col min-h-0"
30
38
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.51.0"
2
+ VERSION = "0.53.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -30,15 +30,18 @@
30
30
  "marked": "^15.0.3"
31
31
  },
32
32
  "devDependencies": {
33
+ "@tabler/icons-vue": "^3.44.0",
33
34
  "@tailwindcss/forms": "^0.5.10",
34
35
  "@tailwindcss/postcss": "^4.3.0",
35
36
  "@tailwindcss/typography": "^0.5.16",
37
+ "asciinema-player": "^3.15.1",
36
38
  "chokidar-cli": "^3.0.0",
37
39
  "concurrently": "^8.2.2",
38
40
  "cssnano": "^7.0.2",
39
41
  "esbuild": "^0.28.0",
40
42
  "esbuild-plugin-manifest": "^1.0.3",
41
43
  "flowbite-typography": "^1.0.5",
44
+ "medium-zoom": "^1.1.0",
42
45
  "mermaid": "^11.15.0",
43
46
  "postcss": "^8.5.14",
44
47
  "postcss-cli": "^11.0.1",
@@ -428,13 +428,21 @@
428
428
  =================== */
429
429
 
430
430
  .pu-checkbox {
431
- @apply size-5 rounded-md bg-white border-2 border-slate-300 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 indeterminate:bg-primary-600 indeterminate:border-primary-600 hover:border-primary-400;
431
+ @apply size-5 rounded-md bg-white border-2 border-slate-300 text-primary-600 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 indeterminate:bg-primary-600 indeterminate:border-primary-600 hover:border-primary-400;
432
432
  }
433
433
 
434
434
  .dark .pu-checkbox {
435
435
  @apply bg-slate-700 border-slate-500 hover:border-primary-400;
436
436
  }
437
437
 
438
+ .pu-radio {
439
+ @apply size-5 rounded-full bg-white border-2 border-slate-300 text-primary-600 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 hover:border-primary-400;
440
+ }
441
+
442
+ .dark .pu-radio {
443
+ @apply bg-slate-700 border-slate-500 hover:border-primary-400;
444
+ }
445
+
438
446
  /* ===================
439
447
  EMPTY STATE
440
448
  =================== */
@@ -486,6 +494,35 @@
486
494
  @apply w-14 px-6 py-4;
487
495
  }
488
496
 
497
+ /* ===================
498
+ DIALOG — shared centered <dialog> surface
499
+ ===================
500
+ Visual tokens reused by Modal::Centered, the dirty-form-guard
501
+ confirm dialog, and Turbo's themed confirm. Keeps bg/border/radius
502
+ and backdrop styling in one place so a design-token change can't
503
+ silently drift across the three. Animation is driven by toggling
504
+ the [data-open] attribute one frame after showModal() — see
505
+ remote_modal_controller for the rationale (the @starting-style /
506
+ allow-discrete spec dance is unreliable across browsers).
507
+ Positioning, size, and the dialog's own opacity/scale transition
508
+ stay on the call site; they vary per dialog. Slideover has its
509
+ own pinned-right surface and intentionally does not opt in. */
510
+ .pu-dialog {
511
+ background-color: var(--pu-surface);
512
+ border: 1px solid var(--pu-border);
513
+ border-radius: var(--pu-radius-lg);
514
+ }
515
+
516
+ .pu-dialog::backdrop {
517
+ background-color: transparent;
518
+ transition: background-color 200ms ease-out;
519
+ }
520
+
521
+ .pu-dialog[data-open]::backdrop {
522
+ background-color: rgb(0 0 0 / 0.6);
523
+ backdrop-filter: blur(4px);
524
+ }
525
+
489
526
  /* ===================
490
527
  ICON RAIL — modern shell
491
528
  =================== */
@@ -34,9 +34,10 @@
34
34
  @apply !hidden;
35
35
  }
36
36
 
37
- /* Main container - Updated to match form input theme */
37
+ /* Sized to match `.pu-input` so selects line up with text/date/email
38
+ inputs in the same row. */
38
39
  .ss-main {
39
- @apply flex flex-row items-center relative select-none w-full px-4 py-3 cursor-pointer border font-medium text-base leading-normal outline-none transition-colors duration-200 overflow-hidden focus:ring-2;
40
+ @apply flex flex-row items-center relative select-none w-full px-3 h-9 cursor-pointer border text-sm outline-none transition-colors duration-200 overflow-hidden focus:ring-2;
40
41
  background-color: var(--pu-input-bg);
41
42
  border-color: var(--pu-input-border);
42
43
  border-radius: var(--pu-radius-md);
@@ -0,0 +1,165 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="dirty-form-guard"
4
+ // Prompts before dismissing a modal form whose contents have changed.
5
+ // Self-disables when not inside a <dialog>, so it's safe to attach to
6
+ // every form unconditionally.
7
+ //
8
+ // Esc is intercepted at the document's capture phase: relying on the
9
+ // dialog's `cancel` event alone proved flaky under rapid/held Esc when
10
+ // the parent dialog uses `closedby="any"`. The cancel listener stays
11
+ // as defense in depth.
12
+ export default class extends Controller {
13
+ static targets = ["confirmDialog"];
14
+
15
+ // Set by controllers, not the user — comparing them would flag
16
+ // every form as dirty on connect (return_to) or on submit (pre_submit).
17
+ static IGNORED_KEYS = new Set(["authenticity_token", "return_to", "pre_submit"]);
18
+
19
+ connect() {
20
+ this.dialog = this.element.closest("dialog");
21
+ if (!this.dialog) return;
22
+
23
+ this.snapshot = this.#serialize();
24
+ this.forceClose = false;
25
+ this.submitting = false;
26
+
27
+ this.onCancel = this.#onCancel.bind(this);
28
+ this.onSubmit = this.#onSubmit.bind(this);
29
+ this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
30
+ this.onConfirmCancel = this.#onConfirmCancel.bind(this);
31
+ this.onKeydown = this.#onKeydown.bind(this);
32
+
33
+ document.addEventListener("keydown", this.onKeydown, true);
34
+ // Capture phase so this runs before remote-modal's cancel handler
35
+ // — that way `defaultPrevented` is visible there if we intervene.
36
+ this.dialog.addEventListener("cancel", this.onCancel, true);
37
+
38
+ this.element.addEventListener("submit", this.onSubmit);
39
+ this.#closeButtons().forEach((btn) =>
40
+ btn.addEventListener("click", this.onCloseButtonClick, true),
41
+ );
42
+
43
+ if (this.hasConfirmDialogTarget) {
44
+ this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
45
+ }
46
+ }
47
+
48
+ disconnect() {
49
+ if (!this.dialog) return;
50
+ document.removeEventListener("keydown", this.onKeydown, true);
51
+ this.dialog.removeEventListener("cancel", this.onCancel, true);
52
+ this.element.removeEventListener("submit", this.onSubmit);
53
+ this.#closeButtons().forEach((btn) =>
54
+ btn.removeEventListener("click", this.onCloseButtonClick, true),
55
+ );
56
+ if (this.hasConfirmDialogTarget) {
57
+ this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
58
+ }
59
+ }
60
+
61
+ async discard() {
62
+ this.forceClose = true;
63
+ await this.#closeConfirm();
64
+ // Hand off to remote-modal so the parent modal animates out
65
+ // instead of snapping shut.
66
+ this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
67
+ }
68
+
69
+ keepEditing() {
70
+ this.#closeConfirm();
71
+ }
72
+
73
+ #closeButtons() {
74
+ if (!this.dialog) return [];
75
+ return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
76
+ }
77
+
78
+ #serialize() {
79
+ const data = new FormData(this.element);
80
+ const enc = encodeURIComponent;
81
+ return [...data.entries()]
82
+ .filter(([key]) => !this.constructor.IGNORED_KEYS.has(key))
83
+ .map(([key, value]) => {
84
+ const v = value instanceof File ? value.name : value;
85
+ return `${enc(key)}=${enc(v)}`;
86
+ })
87
+ .sort()
88
+ .join("&");
89
+ }
90
+
91
+ #isDirty() {
92
+ return this.#serialize() !== this.snapshot;
93
+ }
94
+
95
+ #onSubmit() {
96
+ this.submitting = true;
97
+ }
98
+
99
+ #confirmIsOpen() {
100
+ return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
101
+ }
102
+
103
+ #onKeydown(event) {
104
+ if (event.key !== "Escape") return;
105
+ if (!this.dialog.open) return;
106
+
107
+ // Once the confirm is open, only its buttons may close it.
108
+ if (this.#confirmIsOpen()) {
109
+ event.preventDefault();
110
+ event.stopPropagation();
111
+ event.stopImmediatePropagation();
112
+ return;
113
+ }
114
+
115
+ if (this.forceClose || this.submitting) return;
116
+ if (!this.#isDirty()) return;
117
+
118
+ event.preventDefault();
119
+ event.stopPropagation();
120
+ event.stopImmediatePropagation();
121
+ this.#promptDiscard();
122
+ }
123
+
124
+ #onCancel(event) {
125
+ if (this.forceClose || this.submitting) return;
126
+ if (!this.#isDirty()) return;
127
+ event.preventDefault();
128
+ this.#promptDiscard();
129
+ }
130
+
131
+ #onCloseButtonClick(event) {
132
+ if (this.forceClose || this.submitting) return;
133
+ if (!this.#isDirty()) return;
134
+ event.preventDefault();
135
+ event.stopPropagation();
136
+ this.#promptDiscard();
137
+ }
138
+
139
+ #onConfirmCancel(event) {
140
+ event.preventDefault();
141
+ }
142
+
143
+ #promptDiscard() {
144
+ if (this.hasConfirmDialogTarget) {
145
+ const d = this.confirmDialogTarget;
146
+ d.showModal();
147
+ requestAnimationFrame(() => {
148
+ requestAnimationFrame(() => d.setAttribute("data-open", ""));
149
+ });
150
+ } else if (window.confirm("Discard your changes?")) {
151
+ this.forceClose = true;
152
+ this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
153
+ }
154
+ }
155
+
156
+ async #closeConfirm() {
157
+ if (!this.hasConfirmDialogTarget) return;
158
+ const d = this.confirmDialogTarget;
159
+ if (!d.open) return;
160
+ d.removeAttribute("data-open");
161
+ const animations = d.getAnimations({ subtree: true });
162
+ await Promise.allSettled(animations.map((a) => a.finished));
163
+ d.close();
164
+ }
165
+ }
@@ -6,19 +6,20 @@ export default class extends Controller {
6
6
  }
7
7
 
8
8
  preSubmit() {
9
- // Create a hidden input field
9
+ // Some widgets (e.g. slim-select) dispatch their own change event on top
10
+ // of the native one, so this can fire twice per user action. Remove any
11
+ // prior hidden field before appending a fresh one to avoid duplicates.
12
+ this.element.querySelectorAll('input[name="pre_submit"]').forEach(n => n.remove());
13
+
10
14
  const hiddenField = document.createElement('input');
11
15
  hiddenField.type = 'hidden';
12
16
  hiddenField.name = 'pre_submit';
13
17
  hiddenField.value = 'true';
14
-
15
- // Append it to the form
16
18
  this.element.appendChild(hiddenField);
17
19
 
18
20
  // Skip validation by setting novalidate attribute
19
21
  this.element.setAttribute('novalidate', '');
20
22
 
21
- // Submit the form
22
23
  this.submit();
23
24
  }
24
25