plutonium 0.48.0 → 0.49.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  3. data/CHANGELOG.md +38 -0
  4. data/app/assets/plutonium.js +73 -25
  5. data/app/assets/plutonium.js.map +3 -3
  6. data/app/assets/plutonium.min.js +29 -29
  7. data/app/assets/plutonium.min.js.map +3 -3
  8. data/app/views/plutonium/_flash.html.erb +1 -1
  9. data/config/initializers/pagy.rb +1 -1
  10. data/docs/guides/user-invites.md +64 -0
  11. data/docs/public/templates/plutonium.rb +3 -0
  12. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  13. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  14. data/gemfiles/rails_7.gemfile.lock +27 -1
  15. data/gemfiles/rails_8.0.gemfile.lock +27 -1
  16. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  17. data/lib/generators/pu/gem/actual_db_schema/actual_db_schema_generator.rb +24 -0
  18. data/lib/generators/pu/invites/install_generator.rb +136 -35
  19. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  20. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  21. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  22. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  23. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  24. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  25. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  26. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  27. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  28. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  29. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  30. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  31. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  32. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  33. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  34. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +9 -3
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +6 -3
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +18 -0
  37. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  38. data/lib/plutonium/core/controller.rb +10 -3
  39. data/lib/plutonium/engine.rb +1 -1
  40. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  41. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  42. data/lib/plutonium/invites/controller.rb +14 -1
  43. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  45. data/lib/plutonium/resource/policy.rb +23 -8
  46. data/lib/plutonium/rodauth/controller_methods.rb +5 -1
  47. data/lib/plutonium/ui/color_mode_selector.rb +7 -18
  48. data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -0
  49. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  50. data/lib/plutonium/ui/table/components/pagy_info.rb +1 -1
  51. data/lib/plutonium/version.rb +1 -1
  52. data/package.json +1 -1
  53. data/plutonium.gemspec +16 -0
  54. data/src/js/controllers/color_mode_controller.js +41 -34
  55. data/src/js/controllers/flatpickr_controller.js +23 -0
  56. data/src/js/controllers/sidebar_controller.js +28 -1
  57. metadata +19 -2
@@ -67,16 +67,31 @@ module Plutonium
67
67
  raise ArgumentError, "parent and parent_association must both be provided together"
68
68
  end
69
69
 
70
- # Parent association scoping (nested routes)
71
- # The parent was already entity-scoped during authorization, so children
72
- # accessed through the parent don't need additional entity scoping
70
+ # Parent association scoping (nested routes).
71
+ #
72
+ # The parent context is set on the policy for the whole request, so it
73
+ # leaks into sibling lookups too — e.g. a SecureAssociation field on
74
+ # the child's form authorizes an unrelated resource scope while
75
+ # parent/parent_association are still set. Only apply parent scoping
76
+ # when the relation actually corresponds to the parent's named
77
+ # association; otherwise fall through to entity scoping so we don't
78
+ # produce an incoherent (and silently empty) result.
73
79
  assoc_reflection = parent.class.reflect_on_association(parent_association)
74
- if assoc_reflection.collection?
75
- # has_many: merge with the association's scope
76
- parent.public_send(parent_association).merge(relation)
80
+ if assoc_reflection && relation.klass <= assoc_reflection.klass
81
+ # The parent was already entity-scoped during authorization, so
82
+ # children accessed through the parent don't need additional
83
+ # entity scoping.
84
+ if assoc_reflection.collection?
85
+ # has_many: merge with the association's scope
86
+ parent.public_send(parent_association).merge(relation)
87
+ else
88
+ # has_one: scope by foreign key
89
+ relation.where(assoc_reflection.foreign_key => parent.id)
90
+ end
91
+ elsif entity_scope
92
+ relation.associated_with(entity_scope)
77
93
  else
78
- # has_one: scope by foreign key
79
- relation.where(assoc_reflection.foreign_key => parent.id)
94
+ relation
80
95
  end
81
96
  elsif entity_scope
82
97
  # Entity scoping (multi-tenancy)
@@ -15,7 +15,11 @@ module Plutonium
15
15
  private
16
16
 
17
17
  def root_path
18
- rodauth.login_redirect
18
+ if main_app.routes.url_helpers.respond_to?(:root_path)
19
+ main_app.root_path
20
+ else
21
+ rodauth.login_redirect
22
+ end
19
23
  end
20
24
  end
21
25
  end
@@ -6,32 +6,21 @@ module Plutonium
6
6
  # @example Basic usage
7
7
  # render ColorModeSelector.new
8
8
  class ColorModeSelector < Plutonium::UI::Component::Base
9
- # Common CSS classes used across the component
10
- COMMON_CLASSES = {
11
- button: "inline-flex justify-center items-center p-2 text-[var(--pu-text-muted)] rounded-[var(--pu-radius-md)] cursor-pointer hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors duration-200",
12
- icon: "w-5 h-5"
13
- }.freeze
9
+ BUTTON_CLASSES = "inline-flex justify-center items-center p-2 text-[var(--pu-text-muted)] rounded-[var(--pu-radius-md)] cursor-pointer hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors duration-200"
10
+ ICON_SIZE = 18
11
+ ICON_STROKE = 1.5
14
12
 
15
- # Available color modes with their associated icons and actions
16
- COLOR_MODES = [
17
- {mode: "light", icon: Phlex::TablerIcons::Sun, action: "setLightColorMode"},
18
- {mode: "dark", icon: Phlex::TablerIcons::Moon, action: "setDarkColorMode"}
19
- ].freeze
20
-
21
- # Renders the color mode selector
22
- # @return [void]
23
13
  def view_template
24
14
  button(
25
15
  type: "button",
26
- class: COMMON_CLASSES[:button],
16
+ class: BUTTON_CLASSES,
27
17
  data_controller: "color-mode",
28
18
  data_action: "click->color-mode#toggleMode",
29
- data_color_mode_current_value: "light", # Default to light mode
30
19
  title: "Toggle color mode"
31
20
  ) do
32
- # Both icons rendered, only one visible at a time
33
- render Phlex::TablerIcons::Sun.new(class: "#{COMMON_CLASSES[:icon]} color-mode-icon-light", data: {color_mode_icon: "light"})
34
- render Phlex::TablerIcons::Moon.new(class: "#{COMMON_CLASSES[:icon]} color-mode-icon-dark", data: {color_mode_icon: "dark"})
21
+ render Phlex::TablerIcons::DeviceDesktop.new(size: ICON_SIZE, stroke: ICON_STROKE, class: "color-mode-icon-auto")
22
+ render Phlex::TablerIcons::Sun.new(size: ICON_SIZE, stroke: ICON_STROKE, class: "color-mode-icon-light hidden")
23
+ render Phlex::TablerIcons::Moon.new(size: ICON_SIZE, stroke: ICON_STROKE, class: "color-mode-icon-dark hidden")
35
24
  end
36
25
  end
37
26
  end
@@ -15,6 +15,12 @@ module Plutonium
15
15
  class: "flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0"
16
16
  })
17
17
 
18
+ def render_before_main
19
+ div(class: "absolute top-4 right-4") {
20
+ render Plutonium::UI::ColorModeSelector.new
21
+ }
22
+ end
23
+
18
24
  def render_content(&)
19
25
  render_logo
20
26
 
@@ -35,7 +35,7 @@ module Plutonium
35
35
  def render_content(&)
36
36
  div(
37
37
  id: "sidebar-navigation-content",
38
- data: {turbo_permanent: true},
38
+ data: {turbo_permanent: true, sidebar_target: "scroll"},
39
39
  class: "overflow-y-auto py-5 px-3 h-full bg-[var(--pu-surface)] border-r border-[var(--pu-border)]",
40
40
  &
41
41
  )
@@ -55,7 +55,7 @@ module Plutonium
55
55
  end
56
56
 
57
57
  def page_url(limit)
58
- @pagy.page_url(@pagy.page, limit: limit, client_max_limit: limit)
58
+ @pagy.page_url(@pagy.page, limit: limit, max_limit: limit)
59
59
  end
60
60
  end
61
61
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.48.0"
2
+ VERSION = "0.49.1"
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.48.0",
3
+ "version": "0.49.1",
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",
data/plutonium.gemspec CHANGED
@@ -16,6 +16,22 @@ Gem::Specification.new do |spec|
16
16
 
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
18
 
19
+ spec.post_install_message = <<~MSG
20
+ ⚠️ Plutonium #{Plutonium::VERSION} — breaking change
21
+
22
+ Entity-scoped URL helpers and path params have been renamed from
23
+ `<entity>_scope_*` to `<entity>_scoped_*`.
24
+
25
+ Examples:
26
+ organization_scope_widgets_path → organization_scoped_widgets_path
27
+ params[:organization_scope] → params[:organization_scoped]
28
+
29
+ If you reference these helpers or params directly (e.g. in tests, custom
30
+ redirects, or hand-written links), update them to the new names.
31
+
32
+ Apps that only use `resource_url_for` are unaffected.
33
+ MSG
34
+
19
35
  spec.metadata["homepage_uri"] = spec.homepage
20
36
  spec.metadata["source_code_uri"] = "https://github.com/radioactive-labs/plutonium-core"
21
37
  # spec.metadata["changelog_uri"] = "https://google.com"
@@ -1,66 +1,73 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="color-mode"
4
+ //
5
+ // Shared theme state across the app. localStorage key 'theme' holds one of:
6
+ // 'auto' — follow prefers-color-scheme (default when unset)
7
+ // 'light' — force light
8
+ // 'dark' — force dark
9
+ const ORDER = ['auto', 'light', 'dark'];
10
+
4
11
  export default class extends Controller {
5
12
  static values = { current: String };
6
13
 
7
14
  connect() {
8
- // Set initial mode from localStorage or default
9
- const mode = localStorage.getItem('theme') || "light";
10
- this.setMode(mode);
15
+ this.applyMode(this.readMode());
11
16
 
12
- // Listen for cross-tab theme changes
13
17
  this.handleStorageChange = (e) => {
14
- console.log('Storage event received in color-mode controller:', e.key, e.newValue, e.oldValue)
15
- if (e.key === 'theme' && e.newValue) {
16
- console.log('Updating color-mode theme to:', e.newValue)
17
- this.setMode(e.newValue);
18
- }
18
+ if (e.key === 'theme') this.applyMode(this.readMode());
19
19
  };
20
20
  window.addEventListener('storage', this.handleStorageChange);
21
+
22
+ this.mq = window.matchMedia('(prefers-color-scheme: dark)');
23
+ this.handleMqChange = () => {
24
+ if (this.readMode() === 'auto') this.applyMode('auto');
25
+ };
26
+ this.mq.addEventListener('change', this.handleMqChange);
21
27
  }
22
28
 
23
29
  disconnect() {
24
- // Clean up event listener
25
30
  window.removeEventListener('storage', this.handleStorageChange);
31
+ if (this.mq) this.mq.removeEventListener('change', this.handleMqChange);
26
32
  }
27
33
 
28
34
  toggleMode() {
29
- const current = this.currentValue || "light";
30
- const next = current === "light" ? "dark" : "light";
35
+ const current = this.readMode();
36
+ const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
31
37
  this.setMode(next);
32
38
  }
33
39
 
34
40
  setMode(mode) {
35
- // Update html class
36
- if (mode === "dark") {
37
- document.documentElement.classList.add("dark");
38
- } else {
39
- document.documentElement.classList.remove("dark");
40
- }
41
+ localStorage.setItem('theme', mode);
42
+ this.applyMode(mode);
43
+ }
41
44
 
42
- // Update button state
45
+ applyMode(mode) {
46
+ const effective = this.effectiveMode(mode);
47
+ document.documentElement.classList.toggle('dark', effective === 'dark');
43
48
  this.currentValue = mode;
44
-
45
- // Show/hide icons
46
49
  this.toggleIcons(mode);
50
+ }
47
51
 
48
- // Store in localStorage to trigger storage events in other tabs
49
- localStorage.setItem('theme', mode);
52
+ readMode() {
53
+ const saved = localStorage.getItem('theme');
54
+ return ORDER.includes(saved) ? saved : 'auto';
50
55
  }
51
56
 
52
- toggleIcons(mode) {
53
- const sun = this.element.querySelector(".color-mode-icon-light");
54
- const moon = this.element.querySelector(".color-mode-icon-dark");
57
+ effectiveMode(mode) {
58
+ if (mode === 'light' || mode === 'dark') return mode;
59
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
60
+ }
55
61
 
56
- if (sun && moon) {
57
- if (mode === "light") {
58
- sun.classList.remove("hidden");
59
- moon.classList.add("hidden");
60
- } else {
61
- sun.classList.add("hidden");
62
- moon.classList.remove("hidden");
63
- }
62
+ toggleIcons(mode) {
63
+ const icons = {
64
+ auto: this.element.querySelector(".color-mode-icon-auto"),
65
+ light: this.element.querySelector(".color-mode-icon-light"),
66
+ dark: this.element.querySelector(".color-mode-icon-dark"),
67
+ };
68
+ for (const [key, el] of Object.entries(icons)) {
69
+ if (!el) continue;
70
+ el.classList.toggle("hidden", key !== mode);
64
71
  }
65
72
  }
66
73
  }
@@ -54,7 +54,30 @@ export default class extends Controller {
54
54
  }
55
55
 
56
56
  if (this.modal) {
57
+ // Inside a <dialog> opened via showModal(), the dialog establishes its
58
+ // own containing block in the top layer. flatpickr's default positioning
59
+ // computes document coordinates but the calendar (appended to the
60
+ // dialog) interprets them relative to the dialog's box, placing the
61
+ // calendar far from the input. Append to the modal and reposition
62
+ // manually relative to the modal's bounding rect.
57
63
  options.appendTo = this.modal;
64
+ options.position = (instance) => {
65
+ const input = instance.altInput || instance.input;
66
+ const inputRect = input.getBoundingClientRect();
67
+ const modalRect = this.modal.getBoundingClientRect();
68
+ const cal = instance.calendarContainer;
69
+ const calHeight = cal.offsetHeight;
70
+ const spaceBelow = window.innerHeight - inputRect.bottom;
71
+ const showAbove = spaceBelow < calHeight && inputRect.top > calHeight;
72
+ const top = showAbove
73
+ ? inputRect.top - modalRect.top - calHeight - 2
74
+ : inputRect.bottom - modalRect.top + 2;
75
+ cal.style.top = `${top}px`;
76
+ cal.style.left = `${inputRect.left - modalRect.left}px`;
77
+ cal.style.right = "auto";
78
+ cal.classList.toggle("arrowTop", !showAbove);
79
+ cal.classList.toggle("arrowBottom", showAbove);
80
+ };
58
81
  }
59
82
 
60
83
  return options;
@@ -1,3 +1,30 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- export default class extends Controller {}
3
+ // Persists across controller reconnects so the value saved on
4
+ // turbo:before-render is still available on turbo:render, even though
5
+ // the <aside> hosting this controller is replaced during navigation.
6
+ let savedScrollTop = 0;
7
+
8
+ export default class extends Controller {
9
+ static targets = ["scroll"];
10
+
11
+ connect() {
12
+ this.beforeRender = this.beforeRender.bind(this);
13
+ this.afterRender = this.afterRender.bind(this);
14
+ document.addEventListener("turbo:before-render", this.beforeRender);
15
+ document.addEventListener("turbo:render", this.afterRender);
16
+ }
17
+
18
+ disconnect() {
19
+ document.removeEventListener("turbo:before-render", this.beforeRender);
20
+ document.removeEventListener("turbo:render", this.afterRender);
21
+ }
22
+
23
+ beforeRender() {
24
+ if (this.hasScrollTarget) savedScrollTop = this.scrollTarget.scrollTop;
25
+ }
26
+
27
+ afterRender() {
28
+ if (this.hasScrollTarget) this.scrollTarget.scrollTop = savedScrollTop;
29
+ }
30
+ }
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.48.0
4
+ version: 0.49.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-16 00:00:00.000000000 Z
10
+ date: 2026-05-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -610,6 +610,8 @@ files:
610
610
  - docs/superpowers/plans/2026-04-08-plutonium-skills-overhaul.md
611
611
  - docs/superpowers/plans/2026-04-14-plutonium-testing.md
612
612
  - docs/superpowers/plans/2026-04-14-plutonium-testing.md.tasks.json
613
+ - docs/superpowers/plans/2026-05-06-multi-invite-model-support.md
614
+ - docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json
613
615
  - docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md
614
616
  - docs/superpowers/specs/2026-04-14-plutonium-testing-design.md
615
617
  - esbuild.config.js
@@ -665,6 +667,7 @@ files:
665
667
  - lib/generators/pu/field/renderer/templates/renderer.rb.tt
666
668
  - lib/generators/pu/gem/active_shrine/active_shrine_generator.rb
667
669
  - lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt
670
+ - lib/generators/pu/gem/actual_db_schema/actual_db_schema_generator.rb
668
671
  - lib/generators/pu/gem/annotated/annotated_generator.rb
669
672
  - lib/generators/pu/gem/annotated/templates/.keep
670
673
  - lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake
@@ -1140,6 +1143,20 @@ metadata:
1140
1143
  allowed_push_host: https://rubygems.org
1141
1144
  homepage_uri: https://radioactive-labs.github.io/plutonium-core/
1142
1145
  source_code_uri: https://github.com/radioactive-labs/plutonium-core
1146
+ post_install_message: |
1147
+ ⚠️ Plutonium 0.49.1 — breaking change
1148
+
1149
+ Entity-scoped URL helpers and path params have been renamed from
1150
+ `<entity>_scope_*` to `<entity>_scoped_*`.
1151
+
1152
+ Examples:
1153
+ organization_scope_widgets_path → organization_scoped_widgets_path
1154
+ params[:organization_scope] → params[:organization_scoped]
1155
+
1156
+ If you reference these helpers or params directly (e.g. in tests, custom
1157
+ redirects, or hand-written links), update them to the new names.
1158
+
1159
+ Apps that only use `resource_url_for` are unaffected.
1143
1160
  rdoc_options: []
1144
1161
  require_paths:
1145
1162
  - lib