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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d07d40ddf359fb63528f92bae1e1c10c0ec19d4d8101a7202e1c7ae30a798e90
4
- data.tar.gz: 30fe5b1d2c89bd08552b3d64eb84795bc12e5417805b7ffd6c18f84f5f05d8d1
3
+ metadata.gz: 5016bbce0d2ef39825a923b820797c304a848396853f334d94757f22a90a99e9
4
+ data.tar.gz: aa95cd0b1d994c093dd0e51587d5c95c3b0a527b0ca5a8f771be1a6f21fd9daa
5
5
  SHA512:
6
- metadata.gz: f5652a759d23236d48265376846ed7269a34ab6a7796bb40c6cc2331a7be308a8de472cda0405e7453ce52ae6452bcb291aaf2c784d305a9dbec451d1be3efa8
7
- data.tar.gz: 3aee38cfc32329df17558559fcda9f1cae1c0605dc8c5a70d3e8ccd74ec5a4de9a04082e95b7368a07e812fe9a1b7d59e89405077e1dfb8210241710f59b3397
6
+ metadata.gz: 6aeedc9109ad0ac05e2343981de71497ba08be96017e5af800bde70eb5035a96a027477ec2280ad78a1e9449c530e2c67290663efc28a2cc41f97c492056e1e9
7
+ data.tar.gz: 5b697b2d753c9f80443210c2169f2abe42642f0d36bb1f0aa909965da6317cc1e4c90406560d9bf0156457e5a01b9a009b38250c0aed134b09328d50cb47484f
@@ -46,6 +46,7 @@ rails generate pu:invites:install
46
46
  |--------|---------|-------------|
47
47
  | `--entity-model=NAME` | Entity | Entity model name for scoping |
48
48
  | `--user-model=NAME` | User | User model name |
49
+ | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name. Omit for single-flow apps; set per-invocation when running the generator more than once. |
49
50
  | `--membership-model=NAME` | EntityUser | Membership join model |
50
51
  | `--roles=ROLES` | member,admin | Comma-separated roles |
51
52
  | `--rodauth=NAME` | user | Rodauth configuration for signup |
@@ -113,6 +114,46 @@ get "invitations/:token/signup", to: "invites/user_invitations#signup"
113
114
  post "invitations/:token/signup", to: "invites/user_invitations#signup"
114
115
  ```
115
116
 
117
+ ## Multiple invite flows in one app
118
+
119
+ A single app can run several independent invite flows side-by-side — for example, one for inviting customers to organizations and another for inviting funders to projects. Run `pu:invites:install` once per flow.
120
+
121
+ **Default derivation rule.** When `--invite_model` is omitted, the generator derives the class name as `<EntityModel><UserModel>Invite`. So with the defaults (`--entity_model=Organization --user_model=User`) the generated class is `Invites::OrganizationUserInvite` — there is no literal `UserInvite` default. Single-flow apps don't need to pass `--invite_model` at all.
122
+
123
+ Multi-flow apps typically vary `--entity_model` / `--user_model` per invocation; the derived names diverge automatically, so `--invite_model` is only needed when you want a custom class name.
124
+
125
+ ```bash
126
+ rails g pu:invites:install \
127
+ --entity_model=FunderOrganization \
128
+ --user_model=SpenderAccount \
129
+ --invite_model=FunderInvite
130
+
131
+ rails g pu:invites:install \
132
+ --entity_model=Project \
133
+ --user_model=Member \
134
+ --invite_model=ProjectInvite
135
+ ```
136
+
137
+ Each invocation creates an independent flow: model `Invites::FunderInvite` on `funder_invites`, controller `Invites::FunderInvitationsController` on `/funder_invitations/:token`, helper `funder_invitation_path`, etc. The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array, so `pending_invite` checks all flows in priority order (first-match wins).
138
+
139
+ Override hooks at the model level:
140
+ - `def user_attribute; :spender_account; end` — when `belongs_to :spender_account` instead of `:user`.
141
+ - `def invite_entity_attribute; :funder_organization; end` — when `belongs_to :funder_organization` instead of `:entity`.
142
+
143
+ Override hooks at the controller level (auto-generated by the install generator, shown here so you understand what it emits and can tweak it):
144
+
145
+ ```ruby
146
+ # packages/invites/app/controllers/invites/welcome_controller.rb
147
+ def invite_classes
148
+ [::Invites::FunderInvite, ::Invites::ProjectInvite]
149
+ end
150
+
151
+ # packages/invites/app/controllers/invites/funder_invitations_controller.rb
152
+ def invitation_path_for(token)
153
+ funder_invitation_path(token: token)
154
+ end
155
+ ```
156
+
116
157
  ## Connecting Invitables
117
158
 
118
159
  Invitables are models that trigger invitations and get notified when they're accepted. Common examples:
data/CHANGELOG.md CHANGED
@@ -1,3 +1,41 @@
1
+ ## [0.49.1] - 2026-05-06
2
+
3
+ ### 🚀 Features
4
+
5
+ - *(invites)* Support multiple invite models per app
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - *(policy)* Scope parent association only to matching relations
10
+ - *(ui)* Preserve sidebar scroll across Turbo navigations
11
+ - *(invites)* Add invite_entity_attribute hook for non-:entity invite models
12
+ - *(interactive_actions)* Restore default layout on direct loads
13
+ - *(invites)* Unblock acceptance + non-importmap apps
14
+ - *(interactive_actions)* Force text/html on failure response
15
+ - *(flatpickr)* Position calendar correctly inside modal dialogs
16
+ ## [0.49.0] - 2026-05-04
17
+
18
+ ### 🚀 Features
19
+
20
+ - *(generators)* Add `pu:gem:actual_db_schema` and wire into app template
21
+ - *(ui)* Add auto mode to color mode selector
22
+ - *(ui)* Render color mode selector on rodauth layout
23
+ - *(generators)* Flesh out rails_pulse initializer template
24
+
25
+ ### 🐛 Bug Fixes
26
+
27
+ - *(controller)* Return clean 403 from non-HTML unauthorized handler
28
+ - *(generators)* Align pu:lite:rails_pulse with v0.3 schema flow
29
+ - *(rodauth)* Prefer main_app.root_path over login_redirect
30
+
31
+ ### 🚜 Refactor
32
+
33
+ - *(routing)* Rename entity scope prefix from `_scope` to `_scoped`
34
+
35
+ ### ⚙️ Miscellaneous Tasks
36
+
37
+ - *(pagy)* Rename :client_max_limit to :max_limit
38
+ - Run appraisals
1
39
  ## [0.48.0] - 2026-04-16
2
40
 
3
41
  ### 🚀 Features
@@ -13383,49 +13383,62 @@
13383
13383
  };
13384
13384
 
13385
13385
  // src/js/controllers/color_mode_controller.js
13386
+ var ORDER = ["auto", "light", "dark"];
13386
13387
  var color_mode_controller_default = class extends Controller {
13387
13388
  static values = { current: String };
13388
13389
  connect() {
13389
- const mode = localStorage.getItem("theme") || "light";
13390
- this.setMode(mode);
13390
+ this.applyMode(this.readMode());
13391
13391
  this.handleStorageChange = (e4) => {
13392
- console.log("Storage event received in color-mode controller:", e4.key, e4.newValue, e4.oldValue);
13393
- if (e4.key === "theme" && e4.newValue) {
13394
- console.log("Updating color-mode theme to:", e4.newValue);
13395
- this.setMode(e4.newValue);
13396
- }
13392
+ if (e4.key === "theme")
13393
+ this.applyMode(this.readMode());
13397
13394
  };
13398
13395
  window.addEventListener("storage", this.handleStorageChange);
13396
+ this.mq = window.matchMedia("(prefers-color-scheme: dark)");
13397
+ this.handleMqChange = () => {
13398
+ if (this.readMode() === "auto")
13399
+ this.applyMode("auto");
13400
+ };
13401
+ this.mq.addEventListener("change", this.handleMqChange);
13399
13402
  }
13400
13403
  disconnect() {
13401
13404
  window.removeEventListener("storage", this.handleStorageChange);
13405
+ if (this.mq)
13406
+ this.mq.removeEventListener("change", this.handleMqChange);
13402
13407
  }
13403
13408
  toggleMode() {
13404
- const current = this.currentValue || "light";
13405
- const next = current === "light" ? "dark" : "light";
13409
+ const current = this.readMode();
13410
+ const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
13406
13411
  this.setMode(next);
13407
13412
  }
13408
13413
  setMode(mode) {
13409
- if (mode === "dark") {
13410
- document.documentElement.classList.add("dark");
13411
- } else {
13412
- document.documentElement.classList.remove("dark");
13413
- }
13414
+ localStorage.setItem("theme", mode);
13415
+ this.applyMode(mode);
13416
+ }
13417
+ applyMode(mode) {
13418
+ const effective = this.effectiveMode(mode);
13419
+ document.documentElement.classList.toggle("dark", effective === "dark");
13414
13420
  this.currentValue = mode;
13415
13421
  this.toggleIcons(mode);
13416
- localStorage.setItem("theme", mode);
13422
+ }
13423
+ readMode() {
13424
+ const saved = localStorage.getItem("theme");
13425
+ return ORDER.includes(saved) ? saved : "auto";
13426
+ }
13427
+ effectiveMode(mode) {
13428
+ if (mode === "light" || mode === "dark")
13429
+ return mode;
13430
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
13417
13431
  }
13418
13432
  toggleIcons(mode) {
13419
- const sun = this.element.querySelector(".color-mode-icon-light");
13420
- const moon = this.element.querySelector(".color-mode-icon-dark");
13421
- if (sun && moon) {
13422
- if (mode === "light") {
13423
- sun.classList.remove("hidden");
13424
- moon.classList.add("hidden");
13425
- } else {
13426
- sun.classList.add("hidden");
13427
- moon.classList.remove("hidden");
13428
- }
13433
+ const icons = {
13434
+ auto: this.element.querySelector(".color-mode-icon-auto"),
13435
+ light: this.element.querySelector(".color-mode-icon-light"),
13436
+ dark: this.element.querySelector(".color-mode-icon-dark")
13437
+ };
13438
+ for (const [key, el] of Object.entries(icons)) {
13439
+ if (!el)
13440
+ continue;
13441
+ el.classList.toggle("hidden", key !== mode);
13429
13442
  }
13430
13443
  }
13431
13444
  };
@@ -16871,6 +16884,21 @@ ${text2}</tr>
16871
16884
  }
16872
16885
  if (this.modal) {
16873
16886
  options2.appendTo = this.modal;
16887
+ options2.position = (instance) => {
16888
+ const input = instance.altInput || instance.input;
16889
+ const inputRect = input.getBoundingClientRect();
16890
+ const modalRect = this.modal.getBoundingClientRect();
16891
+ const cal = instance.calendarContainer;
16892
+ const calHeight = cal.offsetHeight;
16893
+ const spaceBelow = window.innerHeight - inputRect.bottom;
16894
+ const showAbove = spaceBelow < calHeight && inputRect.top > calHeight;
16895
+ const top2 = showAbove ? inputRect.top - modalRect.top - calHeight - 2 : inputRect.bottom - modalRect.top + 2;
16896
+ cal.style.top = `${top2}px`;
16897
+ cal.style.left = `${inputRect.left - modalRect.left}px`;
16898
+ cal.style.right = "auto";
16899
+ cal.classList.toggle("arrowTop", !showAbove);
16900
+ cal.classList.toggle("arrowBottom", showAbove);
16901
+ };
16874
16902
  }
16875
16903
  return options2;
16876
16904
  }
@@ -27661,7 +27689,27 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
27661
27689
  };
27662
27690
 
27663
27691
  // src/js/controllers/sidebar_controller.js
27692
+ var savedScrollTop = 0;
27664
27693
  var sidebar_controller_default = class extends Controller {
27694
+ static targets = ["scroll"];
27695
+ connect() {
27696
+ this.beforeRender = this.beforeRender.bind(this);
27697
+ this.afterRender = this.afterRender.bind(this);
27698
+ document.addEventListener("turbo:before-render", this.beforeRender);
27699
+ document.addEventListener("turbo:render", this.afterRender);
27700
+ }
27701
+ disconnect() {
27702
+ document.removeEventListener("turbo:before-render", this.beforeRender);
27703
+ document.removeEventListener("turbo:render", this.afterRender);
27704
+ }
27705
+ beforeRender() {
27706
+ if (this.hasScrollTarget)
27707
+ savedScrollTop = this.scrollTarget.scrollTop;
27708
+ }
27709
+ afterRender() {
27710
+ if (this.hasScrollTarget)
27711
+ this.scrollTarget.scrollTop = savedScrollTop;
27712
+ }
27665
27713
  };
27666
27714
 
27667
27715
  // src/js/controllers/password_visibility_controller.js