plutonium 0.52.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +6 -4
  3. data/.claude/skills/plutonium-tenancy/SKILL.md +9 -4
  4. data/.claude/skills/plutonium-ui/SKILL.md +29 -5
  5. data/CHANGELOG.md +16 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +257 -11
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +39 -39
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +2 -1
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/guides/authentication.md +1 -1
  14. data/docs/guides/custom-actions.md +2 -1
  15. data/docs/guides/customizing-ui.md +6 -5
  16. data/docs/guides/multi-tenancy.md +6 -6
  17. data/docs/guides/theming.md +1 -1
  18. data/docs/public/images/components/avatar.png +0 -0
  19. data/docs/reference/auth/accounts.md +1 -1
  20. data/docs/reference/behavior/policies.md +1 -1
  21. data/docs/reference/configuration.md +61 -0
  22. data/docs/reference/resource/actions.md +2 -1
  23. data/docs/reference/resource/definition.md +4 -3
  24. data/docs/reference/tenancy/entity-scoping.md +12 -13
  25. data/docs/reference/ui/components.md +53 -0
  26. data/docs/reference/ui/forms.md +1 -1
  27. data/docs/reference/ui/pages.md +6 -5
  28. data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
  33. data/lib/plutonium/action/base.rb +43 -63
  34. data/lib/plutonium/configuration.rb +7 -0
  35. data/lib/plutonium/definition/actions.rb +10 -11
  36. data/lib/plutonium/definition/base.rb +29 -0
  37. data/lib/plutonium/helpers/assets_helper.rb +0 -30
  38. data/lib/plutonium/helpers/content_helper.rb +0 -44
  39. data/lib/plutonium/helpers/display_helper.rb +0 -62
  40. data/lib/plutonium/helpers/turbo_helper.rb +0 -4
  41. data/lib/plutonium/helpers.rb +0 -2
  42. data/lib/plutonium/resource/definition.rb +0 -42
  43. data/lib/plutonium/ui/action_button.rb +4 -3
  44. data/lib/plutonium/ui/avatar.rb +182 -0
  45. data/lib/plutonium/ui/component/kit.rb +2 -0
  46. data/lib/plutonium/ui/form/base.rb +16 -2
  47. data/lib/plutonium/ui/form/components/secure_association.rb +3 -2
  48. data/lib/plutonium/ui/form/resource.rb +58 -0
  49. data/lib/plutonium/ui/form/theme.rb +7 -3
  50. data/lib/plutonium/ui/grid/card.rb +10 -26
  51. data/lib/plutonium/ui/modal/base.rb +36 -1
  52. data/lib/plutonium/ui/modal/centered.rb +24 -6
  53. data/lib/plutonium/ui/modal/slideover.rb +26 -11
  54. data/lib/plutonium/ui/nav_user.rb +3 -23
  55. data/lib/plutonium/ui/page/edit.rb +6 -3
  56. data/lib/plutonium/ui/page/interactive_action.rb +5 -3
  57. data/lib/plutonium/ui/page/new.rb +6 -3
  58. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
  59. data/lib/plutonium/version.rb +1 -1
  60. data/package.json +1 -1
  61. data/src/css/components.css +38 -1
  62. data/src/css/slim_select.css +3 -2
  63. data/src/js/controllers/dirty_form_guard_controller.js +165 -0
  64. data/src/js/controllers/register_controllers.js +2 -0
  65. data/src/js/controllers/remote_modal_controller.js +53 -19
  66. data/src/js/turbo/index.js +1 -0
  67. data/src/js/turbo/turbo_confirm.js +128 -0
  68. metadata +10 -6
  69. data/lib/plutonium/helpers/attachment_helper.rb +0 -73
  70. data/lib/plutonium/helpers/table_helper.rb +0 -35
  71. /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
@@ -27588,22 +27588,47 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
27588
27588
  this.originalScrollPosition = window.scrollY;
27589
27589
  this.originalOverflow = document.body.style.overflow;
27590
27590
  this.bodyStateRestored = false;
27591
+ this._closing = false;
27591
27592
  document.body.style.overflow = "hidden";
27592
27593
  this.element.showModal();
27593
- this.element.addEventListener("close", this.handleClose.bind(this));
27594
+ requestAnimationFrame(() => {
27595
+ requestAnimationFrame(() => {
27596
+ this.element.setAttribute("data-open", "");
27597
+ });
27598
+ });
27599
+ this.onCancel = this.#onCancel.bind(this);
27600
+ this.onClose = this.#onClose.bind(this);
27601
+ this.onRequestClose = () => this.#animateClose();
27602
+ this.element.addEventListener("cancel", this.onCancel);
27603
+ this.element.addEventListener("close", this.onClose);
27604
+ this.element.addEventListener("modal:request-close", this.onRequestClose);
27605
+ }
27606
+ disconnect() {
27607
+ this.element.removeEventListener("cancel", this.onCancel);
27608
+ this.element.removeEventListener("close", this.onClose);
27609
+ this.element.removeEventListener("modal:request-close", this.onRequestClose);
27610
+ this.#restoreBodyState();
27594
27611
  }
27595
27612
  close() {
27596
- this.element.close();
27597
- this.restoreBodyState();
27613
+ this.#animateClose();
27598
27614
  }
27599
- disconnect() {
27600
- this.element.removeEventListener("close", this.handleClose);
27601
- this.restoreBodyState();
27615
+ #onCancel(event) {
27616
+ if (event.defaultPrevented) return;
27617
+ event.preventDefault();
27618
+ this.#animateClose();
27602
27619
  }
27603
- handleClose() {
27604
- this.restoreBodyState();
27620
+ #onClose() {
27621
+ this.#restoreBodyState();
27605
27622
  }
27606
- restoreBodyState() {
27623
+ async #animateClose() {
27624
+ if (this._closing) return;
27625
+ this._closing = true;
27626
+ this.element.removeAttribute("data-open");
27627
+ const animations = this.element.getAnimations({ subtree: true });
27628
+ await Promise.allSettled(animations.map((a4) => a4.finished));
27629
+ this.element.close();
27630
+ }
27631
+ #restoreBodyState() {
27607
27632
  if (this.bodyStateRestored) return;
27608
27633
  this.bodyStateRestored = true;
27609
27634
  document.body.style.overflow = this.originalOverflow || "";
@@ -28148,6 +28173,129 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28148
28173
  }
28149
28174
  };
28150
28175
 
28176
+ // src/js/controllers/dirty_form_guard_controller.js
28177
+ var dirty_form_guard_controller_default = class extends Controller {
28178
+ static targets = ["confirmDialog"];
28179
+ // Set by controllers, not the user — comparing them would flag
28180
+ // every form as dirty on connect (return_to) or on submit (pre_submit).
28181
+ static IGNORED_KEYS = /* @__PURE__ */ new Set(["authenticity_token", "return_to", "pre_submit"]);
28182
+ connect() {
28183
+ this.dialog = this.element.closest("dialog");
28184
+ if (!this.dialog) return;
28185
+ this.snapshot = this.#serialize();
28186
+ this.forceClose = false;
28187
+ this.submitting = false;
28188
+ this.onCancel = this.#onCancel.bind(this);
28189
+ this.onSubmit = this.#onSubmit.bind(this);
28190
+ this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
28191
+ this.onConfirmCancel = this.#onConfirmCancel.bind(this);
28192
+ this.onKeydown = this.#onKeydown.bind(this);
28193
+ document.addEventListener("keydown", this.onKeydown, true);
28194
+ this.dialog.addEventListener("cancel", this.onCancel, true);
28195
+ this.element.addEventListener("submit", this.onSubmit);
28196
+ this.#closeButtons().forEach(
28197
+ (btn) => btn.addEventListener("click", this.onCloseButtonClick, true)
28198
+ );
28199
+ if (this.hasConfirmDialogTarget) {
28200
+ this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
28201
+ }
28202
+ }
28203
+ disconnect() {
28204
+ if (!this.dialog) return;
28205
+ document.removeEventListener("keydown", this.onKeydown, true);
28206
+ this.dialog.removeEventListener("cancel", this.onCancel, true);
28207
+ this.element.removeEventListener("submit", this.onSubmit);
28208
+ this.#closeButtons().forEach(
28209
+ (btn) => btn.removeEventListener("click", this.onCloseButtonClick, true)
28210
+ );
28211
+ if (this.hasConfirmDialogTarget) {
28212
+ this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
28213
+ }
28214
+ }
28215
+ async discard() {
28216
+ this.forceClose = true;
28217
+ await this.#closeConfirm();
28218
+ this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
28219
+ }
28220
+ keepEditing() {
28221
+ this.#closeConfirm();
28222
+ }
28223
+ #closeButtons() {
28224
+ if (!this.dialog) return [];
28225
+ return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
28226
+ }
28227
+ #serialize() {
28228
+ const data = new FormData(this.element);
28229
+ const enc = encodeURIComponent;
28230
+ return [...data.entries()].filter(([key]) => !this.constructor.IGNORED_KEYS.has(key)).map(([key, value]) => {
28231
+ const v4 = value instanceof File ? value.name : value;
28232
+ return `${enc(key)}=${enc(v4)}`;
28233
+ }).sort().join("&");
28234
+ }
28235
+ #isDirty() {
28236
+ return this.#serialize() !== this.snapshot;
28237
+ }
28238
+ #onSubmit() {
28239
+ this.submitting = true;
28240
+ }
28241
+ #confirmIsOpen() {
28242
+ return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
28243
+ }
28244
+ #onKeydown(event) {
28245
+ if (event.key !== "Escape") return;
28246
+ if (!this.dialog.open) return;
28247
+ if (this.#confirmIsOpen()) {
28248
+ event.preventDefault();
28249
+ event.stopPropagation();
28250
+ event.stopImmediatePropagation();
28251
+ return;
28252
+ }
28253
+ if (this.forceClose || this.submitting) return;
28254
+ if (!this.#isDirty()) return;
28255
+ event.preventDefault();
28256
+ event.stopPropagation();
28257
+ event.stopImmediatePropagation();
28258
+ this.#promptDiscard();
28259
+ }
28260
+ #onCancel(event) {
28261
+ if (this.forceClose || this.submitting) return;
28262
+ if (!this.#isDirty()) return;
28263
+ event.preventDefault();
28264
+ this.#promptDiscard();
28265
+ }
28266
+ #onCloseButtonClick(event) {
28267
+ if (this.forceClose || this.submitting) return;
28268
+ if (!this.#isDirty()) return;
28269
+ event.preventDefault();
28270
+ event.stopPropagation();
28271
+ this.#promptDiscard();
28272
+ }
28273
+ #onConfirmCancel(event) {
28274
+ event.preventDefault();
28275
+ }
28276
+ #promptDiscard() {
28277
+ if (this.hasConfirmDialogTarget) {
28278
+ const d4 = this.confirmDialogTarget;
28279
+ d4.showModal();
28280
+ requestAnimationFrame(() => {
28281
+ requestAnimationFrame(() => d4.setAttribute("data-open", ""));
28282
+ });
28283
+ } else if (window.confirm("Discard your changes?")) {
28284
+ this.forceClose = true;
28285
+ this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
28286
+ }
28287
+ }
28288
+ async #closeConfirm() {
28289
+ if (!this.hasConfirmDialogTarget) return;
28290
+ const d4 = this.confirmDialogTarget;
28291
+ if (!d4.open) return;
28292
+ d4.removeAttribute("data-open");
28293
+ const animations = d4.getAnimations({ subtree: true });
28294
+ await Promise.allSettled(animations.map((a4) => a4.finished));
28295
+ d4.close();
28296
+ }
28297
+ };
28298
+
28151
28299
  // src/js/controllers/register_controllers.js
28152
28300
  function register_controllers_default(application2) {
28153
28301
  application2.register("password-visibility", password_visibility_controller_default);
@@ -28183,6 +28331,7 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28183
28331
  application2.register("row-click", row_click_controller_default);
28184
28332
  application2.register("view-switcher", view_switcher_controller_default);
28185
28333
  application2.register("autosubmit", autosubmit_controller_default);
28334
+ application2.register("dirty-form-guard", dirty_form_guard_controller_default);
28186
28335
  }
28187
28336
 
28188
28337
  // src/js/turbo/turbo_actions.js
@@ -28196,8 +28345,8 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28196
28345
  if (!frameId) return;
28197
28346
  const frame = document.getElementById(frameId);
28198
28347
  if (!frame) return;
28199
- const dialog = frame.querySelector("dialog");
28200
- if (dialog && typeof dialog.close === "function") dialog.close();
28348
+ const dialog2 = frame.querySelector("dialog");
28349
+ if (dialog2 && typeof dialog2.close === "function") dialog2.close();
28201
28350
  frame.innerHTML = "";
28202
28351
  frame.removeAttribute("src");
28203
28352
  };
@@ -28209,6 +28358,103 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28209
28358
  frame.reload();
28210
28359
  };
28211
28360
 
28361
+ // src/js/turbo/turbo_confirm.js
28362
+ var dialog;
28363
+ var messageEl;
28364
+ var confirmButton;
28365
+ var cancelButton;
28366
+ function ensureDialog() {
28367
+ if (dialog) {
28368
+ if (!dialog.isConnected) document.body.appendChild(dialog);
28369
+ return;
28370
+ }
28371
+ dialog = document.createElement("dialog");
28372
+ dialog.className = [
28373
+ "pu-dialog",
28374
+ "top-1/2",
28375
+ "-translate-y-1/2",
28376
+ "left-1/2",
28377
+ "-translate-x-1/2",
28378
+ "w-full",
28379
+ "max-w-md",
28380
+ "p-0",
28381
+ "open:flex",
28382
+ "flex-col",
28383
+ "opacity-0",
28384
+ "scale-95",
28385
+ "data-[open]:opacity-100",
28386
+ "data-[open]:scale-100",
28387
+ "transition-[opacity,transform]",
28388
+ "duration-200",
28389
+ "ease-out"
28390
+ ].join(" ");
28391
+ dialog.setAttribute("aria-labelledby", "pu-turbo-confirm-message");
28392
+ const header = document.createElement("div");
28393
+ header.className = "px-6 pt-5 pb-4 border-b border-[var(--pu-border)]";
28394
+ messageEl = document.createElement("h2");
28395
+ messageEl.id = "pu-turbo-confirm-message";
28396
+ messageEl.className = "text-lg font-semibold text-[var(--pu-text)]";
28397
+ header.appendChild(messageEl);
28398
+ const footer = document.createElement("div");
28399
+ footer.className = "flex items-center justify-end gap-2 px-6 py-4";
28400
+ cancelButton = document.createElement("button");
28401
+ cancelButton.type = "button";
28402
+ cancelButton.className = "pu-btn pu-btn-md pu-btn-outline";
28403
+ cancelButton.textContent = "Cancel";
28404
+ confirmButton = document.createElement("button");
28405
+ confirmButton.type = "button";
28406
+ confirmButton.className = "pu-btn pu-btn-md pu-btn-primary";
28407
+ confirmButton.textContent = "Confirm";
28408
+ footer.appendChild(cancelButton);
28409
+ footer.appendChild(confirmButton);
28410
+ dialog.appendChild(header);
28411
+ dialog.appendChild(footer);
28412
+ document.body.appendChild(dialog);
28413
+ }
28414
+ async function animateClose() {
28415
+ dialog.removeAttribute("data-open");
28416
+ const animations = dialog.getAnimations({ subtree: true });
28417
+ await Promise.allSettled(animations.map((a4) => a4.finished));
28418
+ if (dialog.open) dialog.close();
28419
+ }
28420
+ function themedConfirm(message) {
28421
+ ensureDialog();
28422
+ messageEl.textContent = message || "Are you sure?";
28423
+ return new Promise((resolve) => {
28424
+ let settled = false;
28425
+ const settle = (value) => {
28426
+ if (settled) return;
28427
+ settled = true;
28428
+ cleanup();
28429
+ resolve(value);
28430
+ animateClose();
28431
+ };
28432
+ const onConfirm = () => settle(true);
28433
+ const onCancel = () => settle(false);
28434
+ const onClose = () => settle(false);
28435
+ const cleanup = () => {
28436
+ confirmButton.removeEventListener("click", onConfirm);
28437
+ cancelButton.removeEventListener("click", onCancel);
28438
+ dialog.removeEventListener("close", onClose);
28439
+ };
28440
+ confirmButton.addEventListener("click", onConfirm);
28441
+ cancelButton.addEventListener("click", onCancel);
28442
+ dialog.addEventListener("close", onClose);
28443
+ dialog.showModal();
28444
+ requestAnimationFrame(() => {
28445
+ requestAnimationFrame(() => dialog.setAttribute("data-open", ""));
28446
+ });
28447
+ confirmButton.focus();
28448
+ });
28449
+ }
28450
+ if (typeof window !== "undefined" && window.Turbo) {
28451
+ if (window.Turbo.config?.forms) {
28452
+ window.Turbo.config.forms.confirm = themedConfirm;
28453
+ } else if (window.Turbo.setConfirmMethod) {
28454
+ window.Turbo.setConfirmMethod(themedConfirm);
28455
+ }
28456
+ }
28457
+
28212
28458
  // src/js/plutonium.js
28213
28459
  var application = Application.start();
28214
28460
  register_controllers_default(application);