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
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.52.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",
@@ -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
+ }
@@ -32,6 +32,7 @@ import CaptureUrlController from "./capture_url_controller.js"
32
32
  import RowClickController from "./row_click_controller.js"
33
33
  import ViewSwitcherController from "./view_switcher_controller.js"
34
34
  import AutosubmitController from "./autosubmit_controller.js"
35
+ import DirtyFormGuardController from "./dirty_form_guard_controller.js"
35
36
 
36
37
  export default function (application) {
37
38
  // Register controllers here
@@ -68,4 +69,5 @@ export default function (application) {
68
69
  application.register("row-click", RowClickController)
69
70
  application.register("view-switcher", ViewSwitcherController)
70
71
  application.register("autosubmit", AutosubmitController)
72
+ application.register("dirty-form-guard", DirtyFormGuardController)
71
73
  }
@@ -1,45 +1,79 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="remote-modal"
4
+ // Drives the open/close lifecycle of a turbo-fetched <dialog>.
5
+ //
6
+ // Entry is animated by deferring `data-open` to the frame after
7
+ // showModal() — the dialog renders one frame with its closed-state
8
+ // transform/opacity, then transitions into the open state. Exit
9
+ // reverses it: remove `data-open`, wait for the dialog's animations
10
+ // to settle, then call close(). This avoids the @starting-style /
11
+ // allow-discrete spec dance, which is unreliable across browsers.
4
12
  export default class extends Controller {
5
13
  connect() {
6
- // Store original scroll position and body overflow
7
14
  this.originalScrollPosition = window.scrollY;
8
15
  this.originalOverflow = document.body.style.overflow;
9
16
  this.bodyStateRestored = false;
10
-
11
- // Lock body scroll
17
+ this._closing = false;
12
18
  document.body.style.overflow = "hidden";
13
19
 
14
- // Show the modal
15
20
  this.element.showModal();
16
- // Add close event listener
17
- this.element.addEventListener("close", this.handleClose.bind(this));
21
+ // Double rAF ensures the closed-state styles paint before we flip
22
+ // data-open, so the transition actually fires.
23
+ requestAnimationFrame(() => {
24
+ requestAnimationFrame(() => {
25
+ this.element.setAttribute("data-open", "");
26
+ });
27
+ });
28
+
29
+ this.onCancel = this.#onCancel.bind(this);
30
+ this.onClose = this.#onClose.bind(this);
31
+ this.onRequestClose = () => this.#animateClose();
32
+
33
+ this.element.addEventListener("cancel", this.onCancel);
34
+ this.element.addEventListener("close", this.onClose);
35
+ this.element.addEventListener("modal:request-close", this.onRequestClose);
36
+ }
37
+
38
+ disconnect() {
39
+ this.element.removeEventListener("cancel", this.onCancel);
40
+ this.element.removeEventListener("close", this.onClose);
41
+ this.element.removeEventListener("modal:request-close", this.onRequestClose);
42
+ this.#restoreBodyState();
18
43
  }
19
44
 
20
45
  close() {
21
- // Close the modal
22
- this.element.close();
23
- this.restoreBodyState();
46
+ this.#animateClose();
24
47
  }
25
48
 
26
- disconnect() {
27
- // Clean up event listener when controller is disconnected
28
- this.element.removeEventListener("close", this.handleClose);
29
- this.restoreBodyState();
49
+ #onCancel(event) {
50
+ // Another listener (typically dirty-form-guard) already handled
51
+ // this — don't double-process.
52
+ if (event.defaultPrevented) return;
53
+ event.preventDefault();
54
+ this.#animateClose();
30
55
  }
31
56
 
32
- handleClose() {
33
- this.restoreBodyState();
57
+ #onClose() {
58
+ this.#restoreBodyState();
34
59
  }
35
60
 
36
- restoreBodyState() {
61
+ async #animateClose() {
62
+ if (this._closing) return;
63
+ this._closing = true;
64
+
65
+ this.element.removeAttribute("data-open");
66
+
67
+ const animations = this.element.getAnimations({ subtree: true });
68
+ await Promise.allSettled(animations.map((a) => a.finished));
69
+
70
+ this.element.close();
71
+ }
72
+
73
+ #restoreBodyState() {
37
74
  if (this.bodyStateRestored) return;
38
75
  this.bodyStateRestored = true;
39
-
40
- // Restore body overflow
41
76
  document.body.style.overflow = this.originalOverflow || "";
42
- // Restore the original scroll position
43
77
  window.scrollTo(0, this.originalScrollPosition);
44
78
  }
45
79
  }
@@ -1,3 +1,4 @@
1
1
  import "./turbo_actions"
2
+ import "./turbo_confirm"
2
3
  // import "./turbo_debug"
3
4
  // import "./turbo_frame_monkey_patch"
@@ -0,0 +1,128 @@
1
+ // Themed replacement for Turbo's default window.confirm. The dialog is
2
+ // built lazily and reused so per-call cost is just a textContent swap.
3
+
4
+ let dialog;
5
+ let messageEl;
6
+ let confirmButton;
7
+ let cancelButton;
8
+
9
+ function ensureDialog() {
10
+ // Turbo Drive replaces document.body on full-page navigation, which
11
+ // detaches the cached dialog. showModal() then throws InvalidStateError
12
+ // ("not in a Document"). Re-attach if detached; the node itself plus
13
+ // its listeners survive, so we don't have to rebuild.
14
+ if (dialog) {
15
+ if (!dialog.isConnected) document.body.appendChild(dialog);
16
+ return;
17
+ }
18
+
19
+ dialog = document.createElement("dialog");
20
+ // Surface (bg, border, radius, backdrop) comes from .pu-dialog; the
21
+ // remaining utilities are positioning, size, and the opacity/scale
22
+ // animation hooks driven by [data-open]. Matches Modal::Centered.
23
+ dialog.className = [
24
+ "pu-dialog",
25
+ "top-1/2",
26
+ "-translate-y-1/2",
27
+ "left-1/2",
28
+ "-translate-x-1/2",
29
+ "w-full",
30
+ "max-w-md",
31
+ "p-0",
32
+ "open:flex",
33
+ "flex-col",
34
+ "opacity-0",
35
+ "scale-95",
36
+ "data-[open]:opacity-100",
37
+ "data-[open]:scale-100",
38
+ "transition-[opacity,transform]",
39
+ "duration-200",
40
+ "ease-out",
41
+ ].join(" ");
42
+ dialog.setAttribute("aria-labelledby", "pu-turbo-confirm-message");
43
+
44
+ const header = document.createElement("div");
45
+ header.className = "px-6 pt-5 pb-4 border-b border-[var(--pu-border)]";
46
+
47
+ messageEl = document.createElement("h2");
48
+ messageEl.id = "pu-turbo-confirm-message";
49
+ messageEl.className = "text-lg font-semibold text-[var(--pu-text)]";
50
+ header.appendChild(messageEl);
51
+
52
+ const footer = document.createElement("div");
53
+ footer.className = "flex items-center justify-end gap-2 px-6 py-4";
54
+
55
+ cancelButton = document.createElement("button");
56
+ cancelButton.type = "button";
57
+ cancelButton.className = "pu-btn pu-btn-md pu-btn-outline";
58
+ cancelButton.textContent = "Cancel";
59
+
60
+ confirmButton = document.createElement("button");
61
+ confirmButton.type = "button";
62
+ confirmButton.className = "pu-btn pu-btn-md pu-btn-primary";
63
+ confirmButton.textContent = "Confirm";
64
+
65
+ footer.appendChild(cancelButton);
66
+ footer.appendChild(confirmButton);
67
+
68
+ dialog.appendChild(header);
69
+ dialog.appendChild(footer);
70
+ document.body.appendChild(dialog);
71
+ }
72
+
73
+ async function animateClose() {
74
+ dialog.removeAttribute("data-open");
75
+ const animations = dialog.getAnimations({ subtree: true });
76
+ await Promise.allSettled(animations.map((a) => a.finished));
77
+ if (dialog.open) dialog.close();
78
+ }
79
+
80
+ function themedConfirm(message) {
81
+ ensureDialog();
82
+ messageEl.textContent = message || "Are you sure?";
83
+
84
+ return new Promise((resolve) => {
85
+ let settled = false;
86
+
87
+ const settle = (value) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ cleanup();
91
+ resolve(value);
92
+ animateClose();
93
+ };
94
+
95
+ const onConfirm = () => settle(true);
96
+ const onCancel = () => settle(false);
97
+ const onClose = () => settle(false);
98
+
99
+ const cleanup = () => {
100
+ confirmButton.removeEventListener("click", onConfirm);
101
+ cancelButton.removeEventListener("click", onCancel);
102
+ dialog.removeEventListener("close", onClose);
103
+ };
104
+
105
+ confirmButton.addEventListener("click", onConfirm);
106
+ cancelButton.addEventListener("click", onCancel);
107
+ // Esc / backdrop / programmatic close — all resolve as cancel.
108
+ dialog.addEventListener("close", onClose);
109
+
110
+ dialog.showModal();
111
+ // Double rAF so the closed-state styles paint before [data-open]
112
+ // flips — same rationale as remote_modal_controller.
113
+ requestAnimationFrame(() => {
114
+ requestAnimationFrame(() => dialog.setAttribute("data-open", ""));
115
+ });
116
+ confirmButton.focus();
117
+ });
118
+ }
119
+
120
+ if (typeof window !== "undefined" && window.Turbo) {
121
+ // Turbo 8 deprecated setConfirmMethod in favor of config.forms.confirm.
122
+ // Prefer the new path; fall back for older Turbo versions still in use.
123
+ if (window.Turbo.config?.forms) {
124
+ window.Turbo.config.forms.confirm = themedConfirm;
125
+ } else if (window.Turbo.setConfirmMethod) {
126
+ window.Turbo.setConfirmMethod(themedConfirm);
127
+ }
128
+ }
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.52.0
4
+ version: 0.53.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-21 00:00:00.000000000 Z
10
+ date: 2026-05-31 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -577,6 +577,7 @@ files:
577
577
  - docs/public/favicon-16x16.png
578
578
  - docs/public/favicon-32x32.png
579
579
  - docs/public/favicon.ico
580
+ - docs/public/images/components/avatar.png
580
581
  - docs/public/images/guides/custom-actions-bulk.png
581
582
  - docs/public/images/guides/multi-tenancy-dashboard.png
582
583
  - docs/public/images/guides/multi-tenancy-welcome.png
@@ -633,6 +634,7 @@ files:
633
634
  - docs/reference/behavior/index.md
634
635
  - docs/reference/behavior/interactions.md
635
636
  - docs/reference/behavior/policies.md
637
+ - docs/reference/configuration.md
636
638
  - docs/reference/index.md
637
639
  - docs/reference/resource/actions.md
638
640
  - docs/reference/resource/definition.md
@@ -668,6 +670,7 @@ files:
668
670
  - docs/superpowers/specs/2026-05-12-skill-compaction-design.md
669
671
  - docs/superpowers/specs/2026-05-13-docs-restructure-design.md
670
672
  - docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md
673
+ - docs/superpowers/specs/2026-05-29-avatar-component-design.md
671
674
  - esbuild.config.js
672
675
  - exe/pug
673
676
  - gemfiles/rails_7.gemfile
@@ -879,13 +882,13 @@ files:
879
882
  - lib/generators/pu/rodauth/templates/app/rodauth/rodauth_app.rb.tt
880
883
  - lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt
881
884
  - lib/generators/pu/rodauth/templates/app/views/_login_form_footer.html.erb.tt
885
+ - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/change_password_notify.text.erb
882
886
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/email_auth.text.erb
883
887
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_disabled.text.erb
884
888
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_locked_out.text.erb
885
889
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_setup.text.erb
886
890
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_unlock_failed.text.erb
887
891
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/otp_unlocked.text.erb
888
- - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/password_changed.text.erb
889
892
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/reset_password.text.erb
890
893
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/reset_password_notify.text.erb
891
894
  - lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/unlock_account.text.erb
@@ -975,10 +978,8 @@ files:
975
978
  - lib/plutonium/helpers.rb
976
979
  - lib/plutonium/helpers/application_helper.rb
977
980
  - lib/plutonium/helpers/assets_helper.rb
978
- - lib/plutonium/helpers/attachment_helper.rb
979
981
  - lib/plutonium/helpers/content_helper.rb
980
982
  - lib/plutonium/helpers/display_helper.rb
981
- - lib/plutonium/helpers/table_helper.rb
982
983
  - lib/plutonium/helpers/turbo_helper.rb
983
984
  - lib/plutonium/helpers/turbo_stream_actions_helper.rb
984
985
  - lib/plutonium/interaction/README.md
@@ -1063,6 +1064,7 @@ files:
1063
1064
  - lib/plutonium/ui.rb
1064
1065
  - lib/plutonium/ui/action_button.rb
1065
1066
  - lib/plutonium/ui/actions_dropdown.rb
1067
+ - lib/plutonium/ui/avatar.rb
1066
1068
  - lib/plutonium/ui/block.rb
1067
1069
  - lib/plutonium/ui/breadcrumbs.rb
1068
1070
  - lib/plutonium/ui/color_mode_selector.rb
@@ -1180,6 +1182,7 @@ files:
1180
1182
  - src/js/controllers/capture_url_controller.js
1181
1183
  - src/js/controllers/clipboard_controller.js
1182
1184
  - src/js/controllers/color_mode_controller.js
1185
+ - src/js/controllers/dirty_form_guard_controller.js
1183
1186
  - src/js/controllers/easymde_controller.js
1184
1187
  - src/js/controllers/filter_panel_controller.js
1185
1188
  - src/js/controllers/flatpickr_controller.js
@@ -1212,6 +1215,7 @@ files:
1212
1215
  - src/js/support/mime_icon.js
1213
1216
  - src/js/turbo/index.js
1214
1217
  - src/js/turbo/turbo_actions.js
1218
+ - src/js/turbo/turbo_confirm.js
1215
1219
  - src/js/turbo/turbo_debug.js
1216
1220
  - src/js/turbo/turbo_frame_monkey_patch.js
1217
1221
  - tailwind.config.js
@@ -1225,7 +1229,7 @@ metadata:
1225
1229
  homepage_uri: https://radioactive-labs.github.io/plutonium-core/
1226
1230
  source_code_uri: https://github.com/radioactive-labs/plutonium-core
1227
1231
  post_install_message: |
1228
- ⚠️ Plutonium 0.52.0 — breaking change
1232
+ ⚠️ Plutonium 0.53.0 — breaking change
1229
1233
 
1230
1234
  Entity-scoped URL helpers and path params have been renamed from
1231
1235
  `<entity>_scope_*` to `<entity>_scoped_*`.
@@ -1,73 +0,0 @@
1
- module Plutonium
2
- module Helpers
3
- module AttachmentHelper
4
- def attachment_preview(attachments, **options)
5
- clamp_content begin
6
- tag.div class: [options[:identity_class], "attachment-preview-container d-flex flex-wrap gap-1 my-1"],
7
- data: {controller: "attachment-preview-container"} do
8
- Array(attachments).each do |attachment|
9
- next unless attachment.url.present?
10
-
11
- concat begin
12
- tag.div class: [options[:identity_class], "attachment-preview d-inline-block text-center"],
13
- title: attachment.filename,
14
- data: {
15
- controller: "attachment-preview",
16
- attachment_preview_mime_type_value: attachment.content_type,
17
- attachment_preview_thumbnail_url_value: _attachment_thumbnail_url(attachment)
18
- } do
19
- tag.figure class: "figure my-1", style: "width: 160px;" do
20
- concat attachment_preview_thumnail(attachment)
21
- concat begin
22
- tag.figcaption class: "figure-caption text-truncate" do
23
- if options[:caption]
24
- caption = options[:caption].is_a?(String) ? options[:caption] : attachment.filename
25
- concat link_to(caption, attachment.url, class: "text-decoration-none", target: :blank)
26
- end
27
-
28
- if block_given?
29
- elements = Array(yield attachment).compact
30
- elements.each { |elem| concat elem }
31
- end
32
- end
33
- end
34
- end
35
- end
36
- end
37
- end
38
- end
39
- end
40
- end
41
-
42
- def attachment_preview_thumnail(attachment)
43
- return unless attachment.url.present?
44
-
45
- # Any changes made here must be reflected in attachment_input_controller#buildPreviewTemplate
46
-
47
- tag.div class: "bg-white border border-gray-200 rounded-lg shadow hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700", data: {attachment_preview_target: "thumbnail"} do
48
- thumbnail_url = _attachment_thumbnail_url(attachment)
49
- link_body = if thumbnail_url
50
- image_tag thumbnail_url, style: "width:100%; height:100%; object-fit: contain;"
51
- else
52
- _attachment_extension(attachment)
53
- end
54
-
55
- link_to link_body, attachment.url, style: "width:150px; height:150px; line-height: 150px;",
56
- class: "d-block text-decoration-none user-select-none fs-5 font-monospace text-body-secondary",
57
- target: :blank,
58
- data: {attachment_preview_target: "thumbnailLink"}
59
- end
60
- end
61
-
62
- private
63
-
64
- def _attachment_thumbnail_url(attachment)
65
- attachment.url if attachment.representable?
66
- end
67
-
68
- def _attachment_extension(attachment)
69
- attachment.try(:extension) || File.extname(attachment.filename.to_s)
70
- end
71
- end
72
- end
73
- end