plutonium 0.49.1 → 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 (106) 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-views/SKILL.md +59 -0
  5. data/CHANGELOG.md +12 -0
  6. data/app/assets/plutonium.css +2 -2
  7. data/app/assets/plutonium.js +369 -25
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +45 -45
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +4 -4
  12. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  13. data/app/views/resource/_resource_grid.html.erb +1 -0
  14. data/config/brakeman.ignore +25 -2
  15. data/docs/reference/definition/actions.md +14 -1
  16. data/docs/reference/definition/index.md +58 -0
  17. data/docs/reference/views/index.md +43 -0
  18. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  19. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  20. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  23. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  24. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  25. data/lib/plutonium/action/base.rb +44 -1
  26. data/lib/plutonium/action/interactive.rb +1 -1
  27. data/lib/plutonium/configuration.rb +4 -0
  28. data/lib/plutonium/definition/actions.rb +3 -0
  29. data/lib/plutonium/definition/base.rb +8 -0
  30. data/lib/plutonium/definition/metadata.rb +40 -0
  31. data/lib/plutonium/definition/views.rb +94 -0
  32. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  33. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  34. data/lib/plutonium/query/base.rb +8 -0
  35. data/lib/plutonium/query/filters/association.rb +30 -8
  36. data/lib/plutonium/query/filters/boolean.rb +5 -0
  37. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  38. data/lib/plutonium/resource/definition.rb +42 -0
  39. data/lib/plutonium/resource/query_object.rb +64 -6
  40. data/lib/plutonium/testing/resource_definition.rb +2 -2
  41. data/lib/plutonium/ui/action_button.rb +4 -2
  42. data/lib/plutonium/ui/component/kit.rb +12 -0
  43. data/lib/plutonium/ui/display/base.rb +3 -1
  44. data/lib/plutonium/ui/display/resource.rb +109 -25
  45. data/lib/plutonium/ui/display/theme.rb +2 -1
  46. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  47. data/lib/plutonium/ui/empty_card.rb +1 -1
  48. data/lib/plutonium/ui/form/base.rb +29 -1
  49. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  50. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  51. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  52. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  53. data/lib/plutonium/ui/form/resource.rb +48 -9
  54. data/lib/plutonium/ui/form/theme.rb +1 -1
  55. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  56. data/lib/plutonium/ui/grid/card.rb +235 -0
  57. data/lib/plutonium/ui/grid/resource.rb +149 -0
  58. data/lib/plutonium/ui/layout/base.rb +37 -1
  59. data/lib/plutonium/ui/layout/header.rb +1 -2
  60. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  61. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  62. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  63. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  64. data/lib/plutonium/ui/modal/base.rb +109 -0
  65. data/lib/plutonium/ui/modal/centered.rb +21 -0
  66. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  67. data/lib/plutonium/ui/page/base.rb +25 -6
  68. data/lib/plutonium/ui/page/edit.rb +13 -1
  69. data/lib/plutonium/ui/page/index.rb +40 -1
  70. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  71. data/lib/plutonium/ui/page/new.rb +13 -1
  72. data/lib/plutonium/ui/page/show.rb +8 -1
  73. data/lib/plutonium/ui/page_header.rb +8 -13
  74. data/lib/plutonium/ui/panel.rb +10 -19
  75. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  76. data/lib/plutonium/ui/tab_list.rb +29 -7
  77. data/lib/plutonium/ui/table/base.rb +106 -0
  78. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  79. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  80. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  81. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  82. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  83. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  84. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  85. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  86. data/lib/plutonium/ui/table/resource.rb +158 -89
  87. data/lib/plutonium/ui/table/theme.rb +14 -5
  88. data/lib/plutonium/version.rb +1 -1
  89. data/lib/plutonium.rb +6 -0
  90. data/package.json +1 -1
  91. data/src/css/components.css +304 -131
  92. data/src/css/tokens.css +101 -85
  93. data/src/js/controllers/autosubmit_controller.js +24 -0
  94. data/src/js/controllers/bulk_actions_controller.js +15 -16
  95. data/src/js/controllers/capture_url_controller.js +14 -0
  96. data/src/js/controllers/filter_panel_controller.js +77 -19
  97. data/src/js/controllers/frame_navigator_controller.js +34 -6
  98. data/src/js/controllers/icon_rail_controller.js +22 -0
  99. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  100. data/src/js/controllers/register_controllers.js +16 -0
  101. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  102. data/src/js/controllers/row_click_controller.js +21 -0
  103. data/src/js/controllers/table_column_menu_controller.js +43 -0
  104. data/src/js/controllers/table_header_controller.js +16 -0
  105. data/src/js/controllers/view_switcher_controller.js +29 -0
  106. metadata +31 -3
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ # Segmented control for switching between index views (Table /
8
+ # Grid). Renders nothing unless at least two views are enabled.
9
+ # Selection is persisted in a per-resource cookie that the
10
+ # server reads on the next request — no `?view=` URL pollution
11
+ # so filters / search / clear-x links don't have to thread it
12
+ # through. The Stimulus controller sets the cookie on click,
13
+ # then reloads.
14
+ class ViewSwitcher < Plutonium::UI::Component::Base
15
+ SEGMENT_LABELS = {
16
+ table: {label: "Table", icon: Phlex::TablerIcons::Table},
17
+ grid: {label: "Grid", icon: Phlex::TablerIcons::LayoutGrid}
18
+ }.freeze
19
+
20
+ def initialize(views:, current:, cookie_name:, cookie_path: "/")
21
+ @views = views
22
+ @current = current
23
+ @cookie_name = cookie_name
24
+ @cookie_path = cookie_path
25
+ end
26
+
27
+ def render?
28
+ @views.size > 1
29
+ end
30
+
31
+ def view_template
32
+ div(
33
+ role: "tablist",
34
+ aria: {label: "View"},
35
+ class: "inline-flex h-8 rounded-md border border-[var(--pu-border)] bg-[var(--pu-surface)] overflow-hidden",
36
+ data: {
37
+ controller: "view-switcher",
38
+ view_switcher_cookie_name_value: @cookie_name,
39
+ view_switcher_cookie_path_value: @cookie_path
40
+ }
41
+ ) do
42
+ @views.each_with_index do |key, i|
43
+ render_segment(key, last: i == @views.length - 1)
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def render_segment(key, last:)
51
+ meta = SEGMENT_LABELS.fetch(key) { {label: key.to_s.titleize, icon: Phlex::TablerIcons::LayoutGrid} }
52
+ active = key == @current
53
+
54
+ classes = ["px-2.5 inline-flex items-center gap-1.5 text-sm transition-colors"]
55
+ classes << "border-r border-[var(--pu-border)]" unless last
56
+ classes << if active
57
+ "bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300"
58
+ else
59
+ "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
60
+ end
61
+
62
+ button(
63
+ type: "button",
64
+ role: "tab",
65
+ class: classes.join(" "),
66
+ title: meta[:label],
67
+ aria: {selected: active.to_s},
68
+ data: {
69
+ action: "click->view-switcher#select",
70
+ view_switcher_view_param: key.to_s
71
+ }
72
+ ) do
73
+ render meta[:icon].new(class: "w-4 h-4 shrink-0")
74
+ span { meta[:label] }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -13,22 +13,55 @@ module Plutonium
13
13
  end
14
14
 
15
15
  def view_template
16
- render_search_bar
17
- render_scopes_bar
16
+ # filter-panel controller wraps everything so the toolbar's
17
+ # filter button AND the FilterPills "+ Filter" pill (a sibling
18
+ # below the toolbar) share the same controller scope and can
19
+ # toggle the slideover rendered alongside them.
20
+ div(data: filter_panel_controller_data) do
21
+ render_scopes_pills
22
+ render_toolbar
23
+
24
+ div(data: bulk_actions_controller_data) do
25
+ render_filter_pills
26
+ render_bulk_actions_toolbar
27
+ collection.empty? ? render_empty_card : render_table
28
+ end
18
29
 
19
- collection.empty? ? render_empty_card : render_table
30
+ render_filter_slideover if current_query_object.filter_definitions.present?
31
+ end
20
32
 
21
33
  render_footer
22
34
  end
23
35
 
24
36
  private
25
37
 
26
- def render_search_bar
27
- TableSearchBar()
38
+ def render_scopes_pills
39
+ TableScopesPills() if current_query_object.scope_definitions.any?
40
+ end
41
+
42
+ def render_toolbar
43
+ TableToolbar(
44
+ query: current_query_object,
45
+ search_url: current_search_url,
46
+ search_value: params.dig(:q, :search) || params[:search],
47
+ views: resource_definition.defined_views,
48
+ current_view: :table,
49
+ view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
51
+ )
52
+ end
53
+
54
+ def render_filter_pills
55
+ TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
28
56
  end
29
57
 
30
- def render_scopes_bar
31
- TableScopesBar()
58
+ def current_search_url
59
+ request.path
60
+ end
61
+
62
+ def render_bulk_actions_toolbar
63
+ return unless bulk_actions.any?
64
+ BulkActionsToolbar(bulk_actions:)
32
65
  end
33
66
 
34
67
  def render_empty_card
@@ -42,91 +75,84 @@ module Plutonium
42
75
  end
43
76
 
44
77
  def render_table
45
- # Wrap table in Stimulus controller for bulk actions
46
- div(data: bulk_actions_controller_data) do
47
- # Bulk actions toolbar (hidden by default, shown when items selected)
48
- BulkActionsToolbar(bulk_actions:) if bulk_actions.any?
49
-
50
- render Plutonium::UI::Table::Base.new(collection) do |table|
51
- # Selection column for bulk actions (hidden by default, Stimulus shows it)
52
- # Use :_selection as column key to avoid conflicts with field columns
53
- # value_key defaults to model's primary_key
78
+ render Plutonium::UI::Table::Base.new(collection) do |table|
79
+ # Selection column only renders when bulk actions exist —
80
+ # the server already knows, so no JS toggle is needed.
81
+ # Use :_selection as column key to avoid conflicts with field columns;
82
+ # value_key defaults to model's primary_key.
83
+ if bulk_actions.any?
54
84
  table.selection_column :_selection,
55
85
  bulk_actions:,
56
86
  policy_resolver: ->(record) { policy_for(record:) }
87
+ end
57
88
 
58
- @resource_fields.each do |name|
59
- # field :name, as: :string
60
- # column :description, class: "text-red-700"
61
- # column :age, align: :end
62
- # column :description do |record|
63
- # record.description&.truncate(50)
64
- # end
65
-
66
- field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
67
-
68
- display_definition = resource_definition.defined_displays[name] || {}
69
- display_options = display_definition[:options] || {}
70
-
71
- column_definition = resource_definition.defined_columns[name] || {}
72
- column_options = column_definition[:options] || {}
73
-
74
- # Check for conditional rendering
75
- condition = column_options[:condition]
76
- conditionally_hidden = condition && !instance_exec(&condition)
77
- next if conditionally_hidden
78
-
79
- tag = column_options[:as] || display_definition[:as] || field_options[:as]
80
-
81
- # Extract field-level options from display_options and column_options
82
- # These are Phlexi field options that should NOT be passed to the tag builder
83
- field_level_keys = [:label, :description, :placeholder]
84
- display_tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
85
- column_tag_attributes = column_options.except(:wrapper, :as, :align, :condition, *field_level_keys)
86
- tag_attributes = display_tag_attributes.merge(column_tag_attributes)
87
- tag_block = if column_definition[:block]
88
- # User-provided blocks receive the raw record for convenience
89
- user_block = column_definition[:block]
90
- ->(wrapped_object, _key) { user_block.call(wrapped_object.unwrapped) }
91
- else
92
- ->(wrapped_object, key) {
93
- f = wrapped_object.field(key)
94
- tag ||= f.inferred_field_component
95
- f.send(:"#{tag}_tag", **tag_attributes)
96
- }
97
- end
98
-
99
- # For table columns, only extract column-level options (label and align)
100
- # Field-level options like description and placeholder don't make sense in table cells
101
- field_options = field_options.except(:condition).merge(**column_options.slice(:align, :label))
102
- table.column name,
103
- **field_options,
104
- sort_params: current_query_object.sort_params_for(name),
105
- &tag_block
89
+ @resource_fields.each do |name|
90
+ field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
91
+
92
+ display_definition = resource_definition.defined_displays[name] || {}
93
+ display_options = display_definition[:options] || {}
94
+
95
+ column_definition = resource_definition.defined_columns[name] || {}
96
+ column_options = column_definition[:options] || {}
97
+
98
+ # Check for conditional rendering
99
+ condition = column_options[:condition]
100
+ conditionally_hidden = condition && !instance_exec(&condition)
101
+ next if conditionally_hidden
102
+
103
+ tag = column_options[:as] || display_definition[:as] || field_options[:as]
104
+
105
+ # Extract field-level options from display_options and column_options
106
+ # These are Phlexi field options that should NOT be passed to the tag builder
107
+ field_level_keys = [:label, :description, :placeholder]
108
+ display_tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
109
+ column_tag_attributes = column_options.except(:wrapper, :as, :align, :condition, *field_level_keys)
110
+ tag_attributes = display_tag_attributes.merge(column_tag_attributes)
111
+ tag_block = if column_definition[:block]
112
+ # User-provided blocks receive the raw record for convenience
113
+ user_block = column_definition[:block]
114
+ ->(wrapped_object, _key) { user_block.call(wrapped_object.unwrapped) }
115
+ else
116
+ ->(wrapped_object, key) {
117
+ f = wrapped_object.field(key)
118
+ tag ||= f.inferred_field_component
119
+ f.send(:"#{tag}_tag", **tag_attributes)
120
+ }
106
121
  end
107
122
 
108
- table.actions do |wrapped_object|
109
- record = wrapped_object.unwrapped
110
- policy = policy_for(record:)
111
-
112
- actions = resource_definition.defined_actions
113
- .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") }
114
- .values
115
-
116
- primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
117
- dropdown_actions = actions.reject { |a| a.category.primary? }.sort_by(&:position)
123
+ # For table columns, only extract column-level options (label and align)
124
+ # Field-level options like description and placeholder don't make sense in table cells
125
+ field_options = field_options.except(:condition).merge(**column_options.slice(:align, :label))
126
+ table.column name,
127
+ **field_options,
128
+ sort_params: current_query_object.sort_params_for(name),
129
+ &tag_block
130
+ end
118
131
 
119
- div(class: "flex items-center gap-1") do
120
- # Primary actions as buttons
121
- primary_actions.each do |action|
122
- url = route_options_to_url(action.route_options, record)
123
- ActionButton(action, url:, variant: :table)
124
- end
132
+ table.actions do |wrapped_object|
133
+ record = wrapped_object.unwrapped
134
+ policy = policy_for(record:)
135
+
136
+ actions = resource_definition.defined_actions
137
+ .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") }
138
+ .values
139
+
140
+ primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
141
+ dropdown_actions = actions.reject { |a| a.category.primary? }.sort_by(&:position)
142
+
143
+ div(class: "flex items-center gap-1") do
144
+ # Primary actions as buttons. The :show action is also
145
+ # tagged so the `row-click` controller on the <tr> can
146
+ # delegate row-body clicks to it.
147
+ primary_actions.each do |action|
148
+ url = route_options_to_url(action.route_options, record)
149
+ data = (action.name == :show) ? {row_click_target: "show"} : {}
150
+ ActionButton(action, url:, variant: :table, data: data)
151
+ end
125
152
 
126
- # Secondary/danger actions in dropdown
127
- if dropdown_actions.any?
128
- RowActionsDropdown(actions: dropdown_actions, record:)
129
- end
153
+ # Secondary/danger actions in dropdown
154
+ if dropdown_actions.any?
155
+ RowActionsDropdown(actions: dropdown_actions, record:)
130
156
  end
131
157
  end
132
158
  end
@@ -139,15 +165,58 @@ module Plutonium
139
165
  .values
140
166
  end
141
167
 
168
+ def filter_panel_controller_data
169
+ {controller: "filter-panel"}
170
+ end
171
+
172
+ # Hash of the current `q` params reduced to filter values only —
173
+ # used as the FilterForm's record so Phlexi prefills inputs from
174
+ # the URL (it reads values via `object[key]` for Hashes).
175
+ def filter_form_values
176
+ raw = params[:q]
177
+ return {} unless raw
178
+
179
+ hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
180
+ hash = hash.deep_symbolize_keys
181
+ hash.except(:search, :scope, :sort_fields, :sort_directions)
182
+ end
183
+
184
+ def render_filter_slideover
185
+ # Backdrop — click-to-close; transparent until panel opens.
186
+ div(
187
+ class: "fixed inset-0 z-40 bg-black/40 opacity-0 pointer-events-none " \
188
+ "transition-opacity duration-200 " \
189
+ "data-[open]:opacity-100 data-[open]:pointer-events-auto",
190
+ data: {filter_panel_target: "backdrop", action: "click->filter-panel#close"}
191
+ )
192
+
193
+ # Panel — fixed slideover from the right; the form inside owns
194
+ # its scroll region and pinned action strip.
195
+ aside(
196
+ class: "fixed top-0 right-0 bottom-0 z-50 w-full sm:w-[420px] max-w-full " \
197
+ "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
198
+ "translate-x-full transition-transform duration-300 ease-out " \
199
+ "data-[open]:translate-x-0 " \
200
+ "flex flex-col",
201
+ role: "dialog",
202
+ aria: {label: "Filters", hidden: "true", modal: "true"},
203
+ data: {filter_panel_target: "panel"}
204
+ ) do
205
+ render Plutonium::UI::Table::Components::FilterForm.new(
206
+ filter_form_values,
207
+ query_object: current_query_object,
208
+ search_url: current_search_url,
209
+ search_value: params.dig(:q, :search) || params[:search]
210
+ )
211
+ end
212
+ end
213
+
142
214
  def bulk_actions_controller_data
143
- {
144
- controller: "bulk-actions",
145
- bulk_actions_has_actions_value: bulk_actions.any?
146
- }
215
+ {controller: "bulk-actions"}
147
216
  end
148
217
 
149
218
  def render_footer
150
- div(class: "lg:sticky lg:dyna:static bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-[var(--pu-body)]") {
219
+ div(class: "lg:sticky bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-[var(--pu-body)]") {
151
220
  TableInfo(pagy_instance)
152
221
  TablePagination(pagy_instance)
153
222
  }
@@ -25,10 +25,11 @@ module Plutonium
25
25
  # Header
26
26
  header: "pu-table-header",
27
27
  header_grouping_cell: "pu-table-header-cell text-center text-sm border-b border-t border-r last:border-r-0 border-[var(--pu-table-border)]",
28
- header_cell: "pu-table-header-cell",
28
+ header_cell: "pu-table-header-cell group",
29
29
  header_cell_content_wrapper: "inline-flex items-center",
30
- header_cell_sort_wrapper: "flex items-center",
30
+ header_cell_sort_wrapper: "flex items-center justify-between gap-1",
31
31
  header_cell_sort_indicator: "ml-1.5",
32
+ header_cell_link: "flex items-center gap-1 cursor-pointer hover:text-[var(--pu-text)]",
32
33
 
33
34
  # Body
34
35
  body_row: "pu-table-body-row",
@@ -36,11 +37,19 @@ module Plutonium
36
37
 
37
38
  # Sorting
38
39
  sort_icon: "w-3 h-3",
39
- sort_icon_active: "text-primary-600 dark:text-primary-400",
40
- sort_icon_inactive: "text-[var(--pu-text-subtle)]",
40
+ sort_icon_active: "ml-1 inline-flex text-primary-600 dark:text-primary-400",
41
+ sort_icon_inactive: "ml-1 inline-flex text-[var(--pu-text-subtle)] opacity-0 group-hover:opacity-100 transition-opacity",
42
+ sort_priority_badge: "ml-1 inline-flex items-center justify-center w-4 h-4 text-[10px] font-semibold rounded bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300",
41
43
  sort_index_clear_link: "ml-2",
42
44
  sort_index_clear_link_text: "text-xs font-bold text-[var(--pu-text-subtle)]",
43
- sort_index_clear_link_icon: "ml-1 text-danger-600 dark:text-danger-400"
45
+ sort_index_clear_link_icon: "ml-1 text-danger-600 dark:text-danger-400",
46
+
47
+ # Column menu
48
+ column_menu_trigger: "p-1 rounded text-[var(--pu-text-subtle)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] opacity-0 group-hover:opacity-100 transition-opacity",
49
+ column_menu_panel: "hidden absolute right-0 top-full mt-1 z-50 min-w-[180px] bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-md shadow-lg p-1",
50
+ column_menu_item: "flex items-center gap-2 px-2 py-1.5 text-sm text-[var(--pu-text)] rounded hover:bg-[var(--pu-surface-alt)] w-full",
51
+ column_menu_item_disabled: "flex items-center gap-2 px-2 py-1.5 text-sm text-[var(--pu-text-subtle)] opacity-60 cursor-not-allowed",
52
+ column_menu_separator: "my-1 border-t border-[var(--pu-border)]"
44
53
  })
45
54
  end
46
55
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.49.1"
2
+ VERSION = "0.50.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/lib/plutonium.rb CHANGED
@@ -22,6 +22,12 @@ module Plutonium
22
22
  # Custom error class for Plutonium-specific exceptions
23
23
  class Error < StandardError; end
24
24
 
25
+ # Turbo frame id used by the modal/slideover renderer. The layout wraps
26
+ # itself in this frame, in_modal? checks against it, and Action and
27
+ # Definition default to targeting it. Kept as a single constant so the
28
+ # frame name lives in one place.
29
+ REMOTE_MODAL_FRAME = "remote_modal"
30
+
25
31
  # Set up Zeitwerk loader for the Plutonium gem
26
32
  # @return [Zeitwerk::Loader] configured Zeitwerk loader instance
27
33
  Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.49.1",
3
+ "version": "0.50.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",