plutonium 0.49.0 → 0.50.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  5. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  6. data/CHANGELOG.md +27 -0
  7. data/app/assets/plutonium.css +2 -2
  8. data/app/assets/plutonium.js +404 -25
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +45 -45
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/app/views/plutonium/_flash.html.erb +1 -1
  13. data/app/views/plutonium/_resource_header.html.erb +4 -4
  14. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  15. data/app/views/resource/_resource_grid.html.erb +1 -0
  16. data/config/brakeman.ignore +25 -2
  17. data/docs/guides/user-invites.md +64 -0
  18. data/docs/reference/definition/actions.md +14 -1
  19. data/docs/reference/definition/index.md +58 -0
  20. data/docs/reference/views/index.md +43 -0
  21. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  22. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  23. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  24. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  25. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  26. data/gemfiles/rails_7.gemfile.lock +1 -1
  27. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  28. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  29. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  30. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  31. data/lib/generators/pu/invites/install_generator.rb +136 -35
  32. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  33. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  34. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  35. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  36. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  37. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  38. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  39. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  40. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  41. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  42. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  43. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  44. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  45. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  46. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  47. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  48. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  49. data/lib/plutonium/action/base.rb +44 -1
  50. data/lib/plutonium/action/interactive.rb +1 -1
  51. data/lib/plutonium/configuration.rb +4 -0
  52. data/lib/plutonium/definition/actions.rb +3 -0
  53. data/lib/plutonium/definition/base.rb +8 -0
  54. data/lib/plutonium/definition/metadata.rb +40 -0
  55. data/lib/plutonium/definition/views.rb +94 -0
  56. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  57. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  58. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  59. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  60. data/lib/plutonium/invites/controller.rb +14 -1
  61. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  62. data/lib/plutonium/query/base.rb +8 -0
  63. data/lib/plutonium/query/filters/association.rb +30 -8
  64. data/lib/plutonium/query/filters/boolean.rb +5 -0
  65. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  66. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  67. data/lib/plutonium/resource/definition.rb +42 -0
  68. data/lib/plutonium/resource/policy.rb +23 -8
  69. data/lib/plutonium/resource/query_object.rb +64 -6
  70. data/lib/plutonium/testing/resource_definition.rb +2 -2
  71. data/lib/plutonium/ui/action_button.rb +4 -2
  72. data/lib/plutonium/ui/component/kit.rb +12 -0
  73. data/lib/plutonium/ui/display/base.rb +3 -1
  74. data/lib/plutonium/ui/display/resource.rb +109 -25
  75. data/lib/plutonium/ui/display/theme.rb +2 -1
  76. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  77. data/lib/plutonium/ui/empty_card.rb +1 -1
  78. data/lib/plutonium/ui/form/base.rb +29 -1
  79. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  80. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  81. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  82. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  83. data/lib/plutonium/ui/form/resource.rb +48 -9
  84. data/lib/plutonium/ui/form/theme.rb +1 -1
  85. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  86. data/lib/plutonium/ui/grid/card.rb +235 -0
  87. data/lib/plutonium/ui/grid/resource.rb +149 -0
  88. data/lib/plutonium/ui/layout/base.rb +37 -1
  89. data/lib/plutonium/ui/layout/header.rb +1 -2
  90. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  91. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  92. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  93. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  94. data/lib/plutonium/ui/modal/base.rb +109 -0
  95. data/lib/plutonium/ui/modal/centered.rb +21 -0
  96. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  97. data/lib/plutonium/ui/page/base.rb +25 -6
  98. data/lib/plutonium/ui/page/edit.rb +13 -1
  99. data/lib/plutonium/ui/page/index.rb +40 -1
  100. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  101. data/lib/plutonium/ui/page/new.rb +13 -1
  102. data/lib/plutonium/ui/page/show.rb +8 -1
  103. data/lib/plutonium/ui/page_header.rb +8 -13
  104. data/lib/plutonium/ui/panel.rb +10 -19
  105. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  106. data/lib/plutonium/ui/tab_list.rb +29 -7
  107. data/lib/plutonium/ui/table/base.rb +106 -0
  108. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  109. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  110. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  111. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  112. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  113. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  114. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  115. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  116. data/lib/plutonium/ui/table/resource.rb +158 -89
  117. data/lib/plutonium/ui/table/theme.rb +14 -5
  118. data/lib/plutonium/version.rb +1 -1
  119. data/lib/plutonium.rb +6 -0
  120. data/package.json +1 -1
  121. data/src/css/components.css +304 -131
  122. data/src/css/tokens.css +101 -85
  123. data/src/js/controllers/autosubmit_controller.js +24 -0
  124. data/src/js/controllers/bulk_actions_controller.js +15 -16
  125. data/src/js/controllers/capture_url_controller.js +14 -0
  126. data/src/js/controllers/filter_panel_controller.js +77 -19
  127. data/src/js/controllers/flatpickr_controller.js +23 -0
  128. data/src/js/controllers/frame_navigator_controller.js +34 -6
  129. data/src/js/controllers/icon_rail_controller.js +22 -0
  130. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  131. data/src/js/controllers/register_controllers.js +16 -0
  132. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  133. data/src/js/controllers/row_click_controller.js +21 -0
  134. data/src/js/controllers/sidebar_controller.js +28 -1
  135. data/src/js/controllers/table_column_menu_controller.js +43 -0
  136. data/src/js/controllers/table_header_controller.js +16 -0
  137. data/src/js/controllers/view_switcher_controller.js +29 -0
  138. metadata +33 -3
@@ -0,0 +1,128 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="icon-rail-flyout"
4
+ // Manages a flyout panel anchored to a trigger element.
5
+ // - Hover or focus the wrapper → open
6
+ // - Mouse leave (with delay) or blur → close
7
+ // - Esc → close immediately
8
+ // - Click trigger → toggle (touch-friendly)
9
+ //
10
+ // On open, the panel is portaled to <body> so it escapes any ancestor
11
+ // transform / overflow:hidden (the rail aside has both). On close,
12
+ // the panel returns to its original parent.
13
+ //
14
+ // IMPORTANT: once portaled, the panel is OUTSIDE the controller's
15
+ // element scope, so this.panelTarget stops resolving. We capture the
16
+ // node into _panel before moving it.
17
+ export default class extends Controller {
18
+ static targets = ["trigger", "panel"]
19
+ static values = {
20
+ closeDelay: { type: Number, default: 150 }
21
+ }
22
+
23
+ connect() {
24
+ this._closeTimer = null
25
+ this._open = false
26
+ this._panel = null
27
+ this._panelHome = null
28
+ this._onPanelEnter = () => {
29
+ if (this._closeTimer) {
30
+ clearTimeout(this._closeTimer)
31
+ this._closeTimer = null
32
+ }
33
+ }
34
+ this._onPanelLeave = () => this.scheduleClose()
35
+ }
36
+
37
+ disconnect() {
38
+ this._returnPanel()
39
+ }
40
+
41
+ open() {
42
+ if (this._closeTimer) {
43
+ clearTimeout(this._closeTimer)
44
+ this._closeTimer = null
45
+ }
46
+ if (this._open) return
47
+ if (!this._panel && !this.hasPanelTarget) return
48
+ this._open = true
49
+ this.element.dataset.flyoutOpen = "true"
50
+ this._portalPanel()
51
+ this._position()
52
+ }
53
+
54
+ scheduleClose() {
55
+ if (this._closeTimer) clearTimeout(this._closeTimer)
56
+ this._closeTimer = setTimeout(() => this.close(), this.closeDelayValue)
57
+ }
58
+
59
+ close() {
60
+ if (!this._open) return
61
+ this._open = false
62
+ delete this.element.dataset.flyoutOpen
63
+ this._returnPanel()
64
+ }
65
+
66
+ toggle(event) {
67
+ event.preventDefault()
68
+ this._open ? this.close() : this.open()
69
+ }
70
+
71
+ closeOnEsc(event) {
72
+ if (event.key === "Escape") this.close()
73
+ }
74
+
75
+ _portalPanel() {
76
+ if (this._panel) return
77
+ // Capture the panel BEFORE moving it — once it leaves the
78
+ // controller element, this.panelTarget no longer resolves.
79
+ const panel = this.panelTarget
80
+ if (!panel) return
81
+ this._panel = panel
82
+ this._panelHome = panel.parentElement
83
+ panel.addEventListener("mouseenter", this._onPanelEnter)
84
+ panel.addEventListener("mouseleave", this._onPanelLeave)
85
+ document.body.appendChild(panel)
86
+ panel.style.display = "block"
87
+ }
88
+
89
+ _returnPanel() {
90
+ if (!this._panel) return
91
+ const panel = this._panel
92
+ panel.removeEventListener("mouseenter", this._onPanelEnter)
93
+ panel.removeEventListener("mouseleave", this._onPanelLeave)
94
+ panel.style.position = ""
95
+ panel.style.left = ""
96
+ panel.style.top = ""
97
+ panel.style.display = ""
98
+ // If the original parent has been morphed away, the panel would
99
+ // orphan in <body>. Drop it instead of re-attaching to a detached
100
+ // home node.
101
+ if (this._panelHome && document.contains(this._panelHome)) {
102
+ this._panelHome.appendChild(panel)
103
+ } else {
104
+ panel.remove()
105
+ }
106
+ this._panel = null
107
+ this._panelHome = null
108
+ }
109
+
110
+ _position() {
111
+ if (!this._panel || !this.hasTriggerTarget) return
112
+ const panel = this._panel
113
+ const triggerRect = this.triggerTarget.getBoundingClientRect()
114
+ panel.style.position = "fixed"
115
+ panel.style.left = `${triggerRect.right + 4}px`
116
+ panel.style.top = `${triggerRect.top}px`
117
+
118
+ // Shift up if the panel would overflow the viewport bottom.
119
+ requestAnimationFrame(() => {
120
+ const panelRect = panel.getBoundingClientRect()
121
+ const viewportH = window.innerHeight
122
+ if (panelRect.bottom > viewportH - 8) {
123
+ const overflow = panelRect.bottom - (viewportH - 8)
124
+ panel.style.top = `${parseFloat(panel.style.top) - overflow}px`
125
+ }
126
+ })
127
+ }
128
+ }
@@ -24,6 +24,14 @@ import BulkActionsController from "./bulk_actions_controller.js"
24
24
  import FilterPanelController from "./filter_panel_controller.js"
25
25
  import TextareaAutogrowController from "./textarea_autogrow_controller.js"
26
26
  import ClipboardController from "./clipboard_controller.js"
27
+ import IconRailController from "./icon_rail_controller.js"
28
+ import IconRailFlyoutController from "./icon_rail_flyout_controller.js"
29
+ import TableHeaderController from "./table_header_controller.js"
30
+ import TableColumnMenuController from "./table_column_menu_controller.js"
31
+ import CaptureUrlController from "./capture_url_controller.js"
32
+ import RowClickController from "./row_click_controller.js"
33
+ import ViewSwitcherController from "./view_switcher_controller.js"
34
+ import AutosubmitController from "./autosubmit_controller.js"
27
35
 
28
36
  export default function (application) {
29
37
  // Register controllers here
@@ -52,4 +60,12 @@ export default function (application) {
52
60
  application.register("filter-panel", FilterPanelController)
53
61
  application.register("textarea-autogrow", TextareaAutogrowController)
54
62
  application.register("clipboard", ClipboardController)
63
+ application.register("icon-rail", IconRailController)
64
+ application.register("icon-rail-flyout", IconRailFlyoutController)
65
+ application.register("table-header", TableHeaderController)
66
+ application.register("table-column-menu", TableColumnMenuController)
67
+ application.register("capture-url", CaptureUrlController)
68
+ application.register("row-click", RowClickController)
69
+ application.register("view-switcher", ViewSwitcherController)
70
+ application.register("autosubmit", AutosubmitController)
55
71
  }
@@ -1,6 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="resource-tab-list"
4
+ //
5
+ // URL hash sync:
6
+ // - On connect, if location.hash matches a tab identifier, that tab is
7
+ // selected (overrides defaultTabValue). Falls back to defaultTabValue
8
+ // or the first tab.
9
+ // - On user click, the URL hash is updated via history.replaceState
10
+ // (no scroll, no back-button entry).
11
+ // - On hashchange (back/forward, manual hash edit), the matching tab
12
+ // is re-selected.
4
13
  export default class extends Controller {
5
14
  static targets = ["btn", "tab"]
6
15
  static values = {
@@ -13,14 +22,37 @@ export default class extends Controller {
13
22
  this.activeClasses = this.hasActiveClassesValue ? this.activeClassesValue.split(" ") : []
14
23
  this.inActiveClasses = this.hasInActiveClassesValue ? this.inActiveClassesValue.split(" ") : []
15
24
 
16
- this.#selectInternal(this.defaultTabValue || this.btnTargets[0].id)
25
+ const fromHash = this.#buttonIdFromHash()
26
+ const initialId = fromHash || this.defaultTabValue || this.btnTargets[0]?.id
27
+ this.#selectInternal(initialId, { skipFocus: true, skipHashUpdate: true })
28
+
29
+ this._syncFromHash = this._syncFromHash.bind(this)
30
+ // hashchange covers manual hash edits and back/forward.
31
+ window.addEventListener("hashchange", this._syncFromHash)
32
+ // turbo:load covers Turbo navigations (including morph) where the URL
33
+ // changes via pushState — which doesn't fire hashchange. Without this,
34
+ // a second submit landing via morph leaves the previously-active tab
35
+ // selected even though the URL hash points elsewhere.
36
+ document.addEventListener("turbo:load", this._syncFromHash)
37
+ }
38
+
39
+ disconnect() {
40
+ if (this._syncFromHash) {
41
+ window.removeEventListener("hashchange", this._syncFromHash)
42
+ document.removeEventListener("turbo:load", this._syncFromHash)
43
+ }
44
+ }
45
+
46
+ _syncFromHash() {
47
+ const id = this.#buttonIdFromHash()
48
+ if (id) this.#selectInternal(id, { skipFocus: true, skipHashUpdate: true })
17
49
  }
18
50
 
19
51
  select(event) {
20
52
  this.#selectInternal(event.currentTarget.id)
21
53
  }
22
54
 
23
- #selectInternal(id) {
55
+ #selectInternal(id, options = {}) {
24
56
  const selectedBtn = this.btnTargets.find(element => element.id === id)
25
57
  if (!selectedBtn) {
26
58
  console.error(`Tab Button with id "${id}" not found`)
@@ -56,9 +88,30 @@ export default class extends Controller {
56
88
  selectedTab.hidden = false
57
89
  selectedTab.setAttribute('aria-hidden', 'false')
58
90
 
91
+ // Sync URL hash so the tab is shareable / restorable on reload.
92
+ if (!options.skipHashUpdate) this.#updateHash(id)
93
+
59
94
  // Focus management
60
- if (selectedBtn !== document.activeElement) {
95
+ if (!options.skipFocus && selectedBtn !== document.activeElement) {
61
96
  selectedBtn.focus()
62
97
  }
63
98
  }
99
+
100
+ // Button ids follow `${identifier}-tab`. The URL hash carries just
101
+ // the identifier (e.g., #details, #orders).
102
+ #buttonIdFromHash() {
103
+ const hash = window.location.hash.replace(/^#/, "")
104
+ if (!hash) return null
105
+ const candidateId = `${hash}-tab`
106
+ const exists = this.btnTargets.some(btn => btn.id === candidateId)
107
+ return exists ? candidateId : null
108
+ }
109
+
110
+ #updateHash(buttonId) {
111
+ const identifier = buttonId.replace(/-tab$/, "")
112
+ const newHash = `#${identifier}`
113
+ if (window.location.hash !== newHash) {
114
+ history.replaceState(null, "", newHash)
115
+ }
116
+ }
64
117
  }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="row-click"
4
+ //
5
+ // Makes a table row or grid card behave like the resource's Show
6
+ // affordance: clicking anywhere except a real interactive element
7
+ // triggers the row's existing Show button. Single source of truth for
8
+ // the URL — turbo-frame targets, modal opening, and any other
9
+ // configuration on the Show action are inherited automatically.
10
+ //
11
+ // Mark the show element with `data-row-click-target="show"`.
12
+ // Opt out of triggering for a specific element with
13
+ // `data-row-click-ignore`.
14
+ export default class extends Controller {
15
+ click(event) {
16
+ if (event.target.closest("a, button, input, label, select, textarea, [data-row-click-ignore]")) {
17
+ return
18
+ }
19
+ this.element.querySelector('[data-row-click-target="show"]')?.click()
20
+ }
21
+ }
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="table-column-menu"
4
+ // Toggles the column ⋯ menu panel; closes on outside click and Escape.
5
+ export default class extends Controller {
6
+ static targets = ["panel"]
7
+
8
+ connect() {
9
+ this._onDocClick = this._onDocClick.bind(this)
10
+ }
11
+
12
+ toggle(event) {
13
+ event.preventDefault()
14
+ event.stopPropagation()
15
+ if (this.hasPanelTarget) {
16
+ const isNowVisible = !this.panelTarget.classList.toggle("hidden")
17
+ if (isNowVisible) {
18
+ document.addEventListener("click", this._onDocClick)
19
+ this._onKey = (e) => { if (e.key === "Escape") this._close() }
20
+ document.addEventListener("keydown", this._onKey)
21
+ } else {
22
+ this._unbind()
23
+ }
24
+ }
25
+ }
26
+
27
+ _close() {
28
+ if (this.hasPanelTarget) this.panelTarget.classList.add("hidden")
29
+ this._unbind()
30
+ }
31
+
32
+ _unbind() {
33
+ document.removeEventListener("click", this._onDocClick)
34
+ if (this._onKey) {
35
+ document.removeEventListener("keydown", this._onKey)
36
+ this._onKey = null
37
+ }
38
+ }
39
+
40
+ _onDocClick(event) {
41
+ if (!this.element.contains(event.target)) this._close()
42
+ }
43
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="table-header"
4
+ // Routes column-header clicks: shift-click navigates to multi-href instead of href,
5
+ // adding the column to the existing sort stack (multi-sort).
6
+ // Plain click lets the default link navigation happen, which replaces all sorts.
7
+ export default class extends Controller {
8
+ headerClick(event) {
9
+ if (!event.shiftKey) return // plain click: let the link navigate normally
10
+ const link = event.currentTarget
11
+ const multiHref = link.dataset.tableHeaderMultiHref
12
+ if (!multiHref) return
13
+ event.preventDefault()
14
+ Turbo.visit(multiHref)
15
+ }
16
+ }
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="view-switcher"
4
+ // Persists the user's chosen index view in a cookie so the server can
5
+ // render the right shape on next request. Reload after writing so the
6
+ // page comes back with everything (toolbar, filters, table/grid)
7
+ // matching the new view.
8
+ export default class extends Controller {
9
+ static values = { cookieName: String, cookiePath: { type: String, default: "/" } }
10
+
11
+ select(event) {
12
+ const view = event.params.view
13
+ if (!view || !this.cookieNameValue) return
14
+
15
+ // 1 year, scoped to the portal's mount path so different portals
16
+ // can hold different view preferences for the same resource.
17
+ // SameSite=Lax keeps it on top-level navigations but blocks
18
+ // cross-site requests from carrying it along.
19
+ const maxAge = 60 * 60 * 24 * 365
20
+ const path = this.cookiePathValue || "/"
21
+ document.cookie = `${this.cookieNameValue}=${encodeURIComponent(view)}; Path=${path}; Max-Age=${maxAge}; SameSite=Lax`
22
+
23
+ // Strip any legacy `?view=` param so the cookie is the source of
24
+ // truth from now on.
25
+ const url = new URL(window.location.href)
26
+ url.searchParams.delete("view")
27
+ window.location.href = url.toString()
28
+ }
29
+ }
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.49.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-04 00:00:00.000000000 Z
10
+ date: 2026-05-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -480,6 +480,7 @@ files:
480
480
  - app/views/resource/_resource_details.html.erb
481
481
  - app/views/resource/_resource_details.rabl
482
482
  - app/views/resource/_resource_form.html.erb
483
+ - app/views/resource/_resource_grid.html.erb
483
484
  - app/views/resource/_resource_table.html.erb
484
485
  - app/views/resource/edit.html.erb
485
486
  - app/views/resource/errors.rabl
@@ -610,8 +611,13 @@ files:
610
611
  - docs/superpowers/plans/2026-04-08-plutonium-skills-overhaul.md
611
612
  - docs/superpowers/plans/2026-04-14-plutonium-testing.md
612
613
  - docs/superpowers/plans/2026-04-14-plutonium-testing.md.tasks.json
614
+ - docs/superpowers/plans/2026-05-06-multi-invite-model-support.md
615
+ - docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json
616
+ - docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md
617
+ - docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json
613
618
  - docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md
614
619
  - docs/superpowers/specs/2026-04-14-plutonium-testing-design.md
620
+ - docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md
615
621
  - esbuild.config.js
616
622
  - exe/pug
617
623
  - gemfiles/rails_7.gemfile
@@ -907,11 +913,13 @@ files:
907
913
  - lib/plutonium/definition/config_attr.rb
908
914
  - lib/plutonium/definition/defineable_props.rb
909
915
  - lib/plutonium/definition/inheritable_config_attr.rb
916
+ - lib/plutonium/definition/metadata.rb
910
917
  - lib/plutonium/definition/nested_inputs.rb
911
918
  - lib/plutonium/definition/presentable.rb
912
919
  - lib/plutonium/definition/scoping.rb
913
920
  - lib/plutonium/definition/search.rb
914
921
  - lib/plutonium/definition/sorting.rb
922
+ - lib/plutonium/definition/views.rb
915
923
  - lib/plutonium/engine.rb
916
924
  - lib/plutonium/engine/validator.rb
917
925
  - lib/plutonium/helpers.rb
@@ -1029,11 +1037,13 @@ files:
1029
1037
  - lib/plutonium/ui/form/base.rb
1030
1038
  - lib/plutonium/ui/form/components/easymde.rb
1031
1039
  - lib/plutonium/ui/form/components/flatpickr.rb
1040
+ - lib/plutonium/ui/form/components/hidden_wrapper.rb
1032
1041
  - lib/plutonium/ui/form/components/intl_tel_input.rb
1033
1042
  - lib/plutonium/ui/form/components/key_value_store.rb
1034
1043
  - lib/plutonium/ui/form/components/resource_select.rb
1035
1044
  - lib/plutonium/ui/form/components/secure_association.rb
1036
1045
  - lib/plutonium/ui/form/components/secure_polymorphic_association.rb
1046
+ - lib/plutonium/ui/form/components/sticky_footer.rb
1037
1047
  - lib/plutonium/ui/form/components/uppy.rb
1038
1048
  - lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb
1039
1049
  - lib/plutonium/ui/form/interaction.rb
@@ -1042,12 +1052,19 @@ files:
1042
1052
  - lib/plutonium/ui/form/resource.rb
1043
1053
  - lib/plutonium/ui/form/theme.rb
1044
1054
  - lib/plutonium/ui/frame_navigator_panel.rb
1055
+ - lib/plutonium/ui/grid/card.rb
1056
+ - lib/plutonium/ui/grid/resource.rb
1045
1057
  - lib/plutonium/ui/layout/base.rb
1046
1058
  - lib/plutonium/ui/layout/basic_layout.rb
1047
1059
  - lib/plutonium/ui/layout/header.rb
1060
+ - lib/plutonium/ui/layout/icon_rail.rb
1048
1061
  - lib/plutonium/ui/layout/resource_layout.rb
1049
1062
  - lib/plutonium/ui/layout/rodauth_layout.rb
1050
1063
  - lib/plutonium/ui/layout/sidebar.rb
1064
+ - lib/plutonium/ui/layout/topbar.rb
1065
+ - lib/plutonium/ui/modal/base.rb
1066
+ - lib/plutonium/ui/modal/centered.rb
1067
+ - lib/plutonium/ui/modal/slideover.rb
1051
1068
  - lib/plutonium/ui/nav_grid_menu.rb
1052
1069
  - lib/plutonium/ui/nav_user.rb
1053
1070
  - lib/plutonium/ui/page/base.rb
@@ -1064,12 +1081,17 @@ files:
1064
1081
  - lib/plutonium/ui/table/base.rb
1065
1082
  - lib/plutonium/ui/table/components/attachment.rb
1066
1083
  - lib/plutonium/ui/table/components/bulk_actions_toolbar.rb
1084
+ - lib/plutonium/ui/table/components/filter_form.rb
1085
+ - lib/plutonium/ui/table/components/filter_pills.rb
1067
1086
  - lib/plutonium/ui/table/components/pagy_info.rb
1068
1087
  - lib/plutonium/ui/table/components/pagy_pagination.rb
1069
1088
  - lib/plutonium/ui/table/components/row_actions_dropdown.rb
1070
1089
  - lib/plutonium/ui/table/components/scopes_bar.rb
1090
+ - lib/plutonium/ui/table/components/scopes_pills.rb
1071
1091
  - lib/plutonium/ui/table/components/search_bar.rb
1072
1092
  - lib/plutonium/ui/table/components/selection_column.rb
1093
+ - lib/plutonium/ui/table/components/toolbar.rb
1094
+ - lib/plutonium/ui/table/components/view_switcher.rb
1073
1095
  - lib/plutonium/ui/table/display_theme.rb
1074
1096
  - lib/plutonium/ui/table/resource.rb
1075
1097
  - lib/plutonium/ui/table/theme.rb
@@ -1100,7 +1122,9 @@ files:
1100
1122
  - src/js/controllers/attachment_input_controller.js
1101
1123
  - src/js/controllers/attachment_preview_container_controller.js
1102
1124
  - src/js/controllers/attachment_preview_controller.js
1125
+ - src/js/controllers/autosubmit_controller.js
1103
1126
  - src/js/controllers/bulk_actions_controller.js
1127
+ - src/js/controllers/capture_url_controller.js
1104
1128
  - src/js/controllers/clipboard_controller.js
1105
1129
  - src/js/controllers/color_mode_controller.js
1106
1130
  - src/js/controllers/easymde_controller.js
@@ -1108,6 +1132,8 @@ files:
1108
1132
  - src/js/controllers/flatpickr_controller.js
1109
1133
  - src/js/controllers/form_controller.js
1110
1134
  - src/js/controllers/frame_navigator_controller.js
1135
+ - src/js/controllers/icon_rail_controller.js
1136
+ - src/js/controllers/icon_rail_flyout_controller.js
1111
1137
  - src/js/controllers/intl_tel_input_controller.js
1112
1138
  - src/js/controllers/key_value_store_controller.js
1113
1139
  - src/js/controllers/nested_resource_form_fields_controller.js
@@ -1119,10 +1145,14 @@ files:
1119
1145
  - src/js/controllers/resource_drop_down_controller.js
1120
1146
  - src/js/controllers/resource_header_controller.js
1121
1147
  - src/js/controllers/resource_tab_list_controller.js
1148
+ - src/js/controllers/row_click_controller.js
1122
1149
  - src/js/controllers/select_navigator.js
1123
1150
  - src/js/controllers/sidebar_controller.js
1124
1151
  - src/js/controllers/slim_select_controller.js
1152
+ - src/js/controllers/table_column_menu_controller.js
1153
+ - src/js/controllers/table_header_controller.js
1125
1154
  - src/js/controllers/textarea_autogrow_controller.js
1155
+ - src/js/controllers/view_switcher_controller.js
1126
1156
  - src/js/core.js
1127
1157
  - src/js/plutonium.js
1128
1158
  - src/js/support/dom_element.js
@@ -1142,7 +1172,7 @@ metadata:
1142
1172
  homepage_uri: https://radioactive-labs.github.io/plutonium-core/
1143
1173
  source_code_uri: https://github.com/radioactive-labs/plutonium-core
1144
1174
  post_install_message: |
1145
- ⚠️ Plutonium 0.49.0 — breaking change
1175
+ ⚠️ Plutonium 0.50.0 — breaking change
1146
1176
 
1147
1177
  Entity-scoped URL helpers and path params have been renamed from
1148
1178
  `<entity>_scope_*` to `<entity>_scoped_*`.