plutonium 0.58.0 → 0.59.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +4 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +49 -0
  4. data/CHANGELOG.md +15 -0
  5. data/app/assets/plutonium.css +1 -1
  6. data/app/assets/plutonium.js +7 -2
  7. data/app/assets/plutonium.js.map +2 -2
  8. data/app/assets/plutonium.min.js +1 -1
  9. data/app/assets/plutonium.min.js.map +2 -2
  10. data/docs/.vitepress/config.ts +1 -0
  11. data/docs/reference/resource/actions.md +3 -0
  12. data/docs/reference/resource/export.md +94 -0
  13. data/docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md +306 -0
  14. data/gemfiles/rails_7.gemfile.lock +3 -1
  15. data/gemfiles/rails_8.0.gemfile.lock +3 -1
  16. data/gemfiles/rails_8.1.gemfile.lock +3 -1
  17. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +3 -3
  18. data/lib/plutonium/definition/base.rb +3 -0
  19. data/lib/plutonium/query/filter.rb +4 -1
  20. data/lib/plutonium/query/filters/association.rb +1 -2
  21. data/lib/plutonium/resource/controller.rb +1 -0
  22. data/lib/plutonium/resource/controllers/export_csv.rb +162 -0
  23. data/lib/plutonium/resource/controllers/queryable.rb +1 -0
  24. data/lib/plutonium/resource/policy.rb +21 -0
  25. data/lib/plutonium/routing/mapper_extensions.rb +13 -0
  26. data/lib/plutonium/ui/export_button.rb +86 -0
  27. data/lib/plutonium/ui/layout/base.rb +10 -11
  28. data/lib/plutonium/ui/layout/resource_layout.rb +18 -0
  29. data/lib/plutonium/ui/table/components/toolbar.rb +9 -2
  30. data/lib/plutonium/ui/table/resource.rb +18 -1
  31. data/lib/plutonium/version.rb +1 -1
  32. data/package.json +1 -1
  33. data/plutonium.gemspec +1 -0
  34. data/src/css/components.css +11 -11
  35. data/src/js/controllers/icon_rail_controller.js +11 -2
  36. metadata +20 -2
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Plutonium
6
+ module Resource
7
+ module Controllers
8
+ # Streams the current resource collection as a CSV download.
9
+ #
10
+ # Auto-mounted on every Plutonium resource via the
11
+ # `interactive_resource_actions` routing concern (see
12
+ # Plutonium::Routing::MapperExtensions). Gated by the `export_csv?`
13
+ # policy method, which defaults to `false` — export is strictly
14
+ # opt-in (enable it by overriding `export_csv?` to return true).
15
+ #
16
+ # The exported rows are exactly the index's filtered collection
17
+ # (`filtered_resource_collection`) — same search, filters, scope, and
18
+ # tenant/parent scoping — but NOT paginated: every matching record is
19
+ # exported. Rows are streamed (a lazy Enumerator body + `find_each`) so
20
+ # memory stays flat regardless of row count.
21
+ #
22
+ # Columns come from `policy.permitted_attributes_for_export` (defaults
23
+ # to the index columns), with the primary key always prepended as the
24
+ # first column. Per-field output and headers are customizable through
25
+ # the definition's `export` DSL.
26
+ #
27
+ # `find_each` iterates in primary-key order, so the file does not
28
+ # preserve the index's current sort (filters/search/scope still apply).
29
+ #
30
+ # Streaming uses a lazy Enumerator response body rather than
31
+ # `send_stream` — the latter lives in ActionController::Live, which
32
+ # would turn *every* resource action into a threaded streaming
33
+ # response. The Enumerator body streams through Rack on its own.
34
+ module ExportCsv
35
+ extend ActiveSupport::Concern
36
+
37
+ # Placeholder written when a column is neither an `export` block nor a
38
+ # real attribute on the record, so the export degrades to a usable file
39
+ # instead of a mid-stream NoMethodError (which would truncate the
40
+ # already-committed download).
41
+ INVALID_COLUMN = "<<invalid column>>"
42
+
43
+ included do
44
+ before_action :authorize_export_csv!, only: :export_csv
45
+ # Row-level authorization is the scope itself
46
+ # (current_authorized_scope via filtered_resource_collection), so
47
+ # the after_action scope verifier is redundant here.
48
+ skip_verify_current_authorized_scope only: :export_csv
49
+ end
50
+
51
+ # GET /<resources>/export_csv
52
+ def export_csv
53
+ response.headers["Content-Type"] = "text/csv; charset=utf-8"
54
+ response.headers["Content-Disposition"] =
55
+ ActionDispatch::Http::ContentDisposition.format(disposition: "attachment", filename: export_csv_filename)
56
+ # Defeat proxy/`Rack::ETag` buffering so rows flush as they're read.
57
+ response.headers["X-Accel-Buffering"] = "no"
58
+ response.headers["Cache-Control"] = "no-cache"
59
+
60
+ self.response_body = export_csv_lines
61
+ end
62
+
63
+ private
64
+
65
+ def authorize_export_csv!
66
+ authorize_current! resource_class, to: :export_csv?
67
+ end
68
+
69
+ def export_csv_filename
70
+ suffix = export_all_requested? ? "_all" : ""
71
+ "#{export_csv_basename}#{suffix}_#{Date.current}.csv"
72
+ end
73
+
74
+ # The human resource name, slugified for a filesystem-friendly file
75
+ # (Blogging::Post → "posts", not the route key "blogging_posts").
76
+ def export_csv_basename
77
+ helpers.resource_name_plural(resource_class).parameterize(separator: "_")
78
+ end
79
+
80
+ # Which records to export. Two modes:
81
+ # - default — the index's filtered collection (current scope,
82
+ # filters, and search via `?q`).
83
+ # - `?all=1` — the entire authorized scope, bypassing the query
84
+ # object entirely (no scope/filter/search/default-scope).
85
+ # Both still respect tenant/parent scoping (current_authorized_scope).
86
+ def export_csv_collection
87
+ export_all_requested? ? current_authorized_scope : filtered_resource_collection
88
+ end
89
+
90
+ def export_all_requested?
91
+ ActiveModel::Type::Boolean.new.cast(params[:all])
92
+ end
93
+
94
+ # A lazy line enumerator: the header row, then one CSV line per
95
+ # record streamed via `find_each` (bounded memory). Pure with
96
+ # respect to the response, so it's unit-testable on its own.
97
+ def export_csv_lines
98
+ columns = export_columns
99
+ Enumerator.new do |yielder|
100
+ yielder << export_csv_row(columns.map { |name| export_csv_header(name) })
101
+ export_csv_collection.find_each do |record|
102
+ yielder << export_csv_row(columns.map { |name| export_csv_value(record, name) })
103
+ end
104
+ end
105
+ end
106
+
107
+ # Serializes one row, neutralizing spreadsheet formula injection per cell.
108
+ def export_csv_row(cells)
109
+ CSV.generate_line(cells.map { |cell| neutralize_csv_formula(cell) })
110
+ end
111
+
112
+ # A cell beginning with = + - @ (or a leading tab/CR) is executed as a
113
+ # formula by Excel/Sheets. Prefix it with a single quote so the value
114
+ # imports as literal text (CSV/formula injection).
115
+ def neutralize_csv_formula(value)
116
+ string = value.to_s
117
+ /\A[=+\-@\t\r]/.match?(string) ? "'#{string}" : string
118
+ end
119
+
120
+ # The primary key is always the first column, followed by the
121
+ # policy's exportable attributes (de-duplicated so an explicitly
122
+ # listed primary key isn't repeated).
123
+ def export_columns
124
+ primary_key = resource_class.primary_key.to_sym
125
+ [primary_key] + (exportable_attributes.map(&:to_sym) - [primary_key])
126
+ end
127
+
128
+ def exportable_attributes
129
+ @exportable_attributes ||= current_policy.send_with_report(:permitted_attributes_for_export)
130
+ end
131
+
132
+ # Resolves a cell's value. An `export` block (definition DSL) takes
133
+ # precedence; otherwise the attribute is read off the record.
134
+ # Associations render as their display label — the same as the index —
135
+ # instead of "#<User:0x…>"; scalars pass through untouched. A name that
136
+ # is neither an `export` block nor a real attribute renders the
137
+ # INVALID_COLUMN placeholder rather than aborting the stream.
138
+ def export_csv_value(record, name)
139
+ definition = current_definition.defined_exports[name]
140
+ return definition[:block].call(record) if definition && definition[:block]
141
+
142
+ begin
143
+ value = record.public_send(name)
144
+ rescue NoMethodError
145
+ return INVALID_COLUMN
146
+ end
147
+
148
+ case value
149
+ when ActiveRecord::Base then helpers.display_name_of(value)
150
+ when ActiveRecord::Relation then helpers.display_name_of(value.to_a)
151
+ else value
152
+ end
153
+ end
154
+
155
+ def export_csv_header(name)
156
+ definition = current_definition.defined_exports[name]
157
+ definition&.dig(:options, :label) || name.to_s.humanize
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -40,6 +40,7 @@ module Plutonium
40
40
  filter_class = Plutonium::Query::Filter.lookup(with)
41
41
  options = value[:options].except(:with)
42
42
  options[:key] ||= key
43
+ options[:resource_class] ||= resource_class
43
44
  with = filter_class.new(**options)
44
45
  end
45
46
  query_object.define_filter key, with, &value[:block]
@@ -186,6 +186,16 @@ module Plutonium
186
186
  index?
187
187
  end
188
188
 
189
+ # Checks if CSV export is permitted.
190
+ #
191
+ # Defaults to false so export is strictly opt-in. Enable it per
192
+ # resource by overriding to return true (or delegating to index?).
193
+ #
194
+ # @return [Boolean] false by default.
195
+ def export_csv?
196
+ false
197
+ end
198
+
189
199
  # Core attributes
190
200
 
191
201
  # Returns the permitted attributes for the create action.
@@ -228,6 +238,17 @@ module Plutonium
228
238
  permitted_attributes_for_read
229
239
  end
230
240
 
241
+ # Returns the attributes included in an export (e.g. CSV columns).
242
+ #
243
+ # Format-agnostic on purpose (named `_export`, not `_export_csv`) so a
244
+ # future export format can reuse the same column set. Defaults to the
245
+ # index columns; override to tailor the exported columns.
246
+ #
247
+ # @return [Array<Symbol>] Delegates to permitted_attributes_for_index.
248
+ def permitted_attributes_for_export
249
+ permitted_attributes_for_index
250
+ end
251
+
231
252
  # Returns the permitted attributes for the new action.
232
253
  #
233
254
  # @return [Array<Symbol>] Delegates to permitted_attributes_for_create.
@@ -42,6 +42,7 @@ module Plutonium
42
42
  define_member_interactive_actions
43
43
  define_collection_interactive_actions
44
44
  define_collection_typeahead_actions
45
+ define_collection_export_actions
45
46
  end
46
47
  end
47
48
 
@@ -181,6 +182,18 @@ module Plutonium
181
182
  as: :typeahead_filter
182
183
  end
183
184
  end
185
+
186
+ # Defines the collection-level CSV export action. Auto-mounted on
187
+ # every Plutonium resource alongside typeahead and bulk actions.
188
+ # The action itself is gated by the `export_csv?` policy (default
189
+ # false), so the route is harmless until a resource opts in.
190
+ #
191
+ # @return [void]
192
+ def define_collection_export_actions
193
+ collection do
194
+ get "export_csv", action: :export_csv, as: :export_csv
195
+ end
196
+ end
184
197
  end
185
198
  end
186
199
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ # Connected split "Export" control for the index toolbar (sits beside
6
+ # the Filter button and shares its `pu-btn-outline pu-btn-sm` styling).
7
+ #
8
+ # The primary button exports the current view (selected scope + filters
9
+ # + search). The caret opens a menu with "Export all", which exports the
10
+ # entire authorized scope.
11
+ #
12
+ # Both links carry `target="_blank"`, which opens the streamed download
13
+ # in a new tab and bypasses Turbo (Turbo ignores links with a `target`).
14
+ #
15
+ # The two halves are joined into one control by flattening their shared
16
+ # inner corners via inline styles (`rounded-*-none` utilities aren't in
17
+ # the packaged stylesheet), so it reads as a single button rather than
18
+ # two pills.
19
+ class ExportButton < Plutonium::UI::Component::Base
20
+ # Inline corner/border tweaks that join the two halves seamlessly.
21
+ PRIMARY_STYLE = "border-top-right-radius:0;border-bottom-right-radius:0"
22
+ CARET_STYLE = "border-top-left-radius:0;border-bottom-left-radius:0;border-left-width:0;padding-left:0.375rem;padding-right:0.375rem"
23
+
24
+ def initialize(scoped_url:, all_url:)
25
+ @scoped_url = scoped_url
26
+ @all_url = all_url
27
+ end
28
+
29
+ def view_template
30
+ div(class: "relative inline-flex", data: {controller: "resource-drop-down"}) do
31
+ render_primary
32
+ render_caret
33
+ render_menu
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def render_primary
40
+ a(
41
+ href: @scoped_url,
42
+ target: "_blank",
43
+ rel: "noopener",
44
+ class: "pu-btn pu-btn-outline pu-btn-sm",
45
+ style: PRIMARY_STYLE
46
+ ) do
47
+ render Phlex::TablerIcons::Download.new(class: "w-4 h-4 shrink-0")
48
+ span { "Export" }
49
+ end
50
+ end
51
+
52
+ def render_caret
53
+ button(
54
+ type: "button",
55
+ class: "pu-btn pu-btn-outline pu-btn-sm",
56
+ style: CARET_STYLE,
57
+ aria: {expanded: "false", haspopup: "menu", label: "More export options"},
58
+ data: {resource_drop_down_target: "trigger"}
59
+ ) do
60
+ render Phlex::TablerIcons::ChevronDown.new(class: "w-4 h-4")
61
+ end
62
+ end
63
+
64
+ def render_menu
65
+ div(
66
+ class: "hidden absolute right-0 top-full z-50 mt-1 w-48 origin-top-right bg-[var(--pu-surface)] " \
67
+ "border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
68
+ style: "box-shadow: var(--pu-shadow-lg)",
69
+ data: {resource_drop_down_target: "menu"}
70
+ ) do
71
+ div(class: "py-1") do
72
+ a(
73
+ href: @all_url,
74
+ target: "_blank",
75
+ rel: "noopener",
76
+ class: "flex items-center gap-2 px-4 py-2 text-sm text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
77
+ ) do
78
+ render Phlex::TablerIcons::Download.new(class: "w-4 h-4")
79
+ span { "Export all" }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -44,12 +44,10 @@ module Plutonium
44
44
  # preferences read from localStorage:
45
45
  # - Color mode: applies `dark` class on <html> so dark theme renders
46
46
  # from the first frame instead of flashing light.
47
- # - Rail-pin: the rail is pinned by default, so this applies
48
- # `pu-rail-pinned` on <body> (when present) and on every incoming
49
- # body via turbo:before-render unless the user explicitly collapsed
50
- # it (localStorage "false"), so a Turbo.visit (e.g. the redirect
51
- # after a form submit) doesn't flash the rail into its collapsed
52
- # state before the icon-rail Stimulus controller can restore it.
47
+ # - Rail-pin: a turbo:before-render listener adds/removes `pu-rail-pinned`
48
+ # on <html> based on whether the incoming body contains an icon-rail,
49
+ # preventing layout shift on Turbo navigations between rail and non-rail
50
+ # pages. Initial-load rail-pinned is handled by ResourceLayout.
53
51
  def render_pre_paint_scripts
54
52
  script do
55
53
  raw(safe(<<~JS))
@@ -63,11 +61,12 @@ module Plutonium
63
61
  } catch (e) {}
64
62
 
65
63
  try {
66
- if (localStorage.getItem("pu_rail_pinned") === "false") return;
67
- if (document.body) document.body.classList.add("pu-rail-pinned");
68
- document.addEventListener("turbo:before-render", function (event) {
69
- if (localStorage.getItem("pu_rail_pinned") !== "false") {
70
- event.detail.newBody.classList.add("pu-rail-pinned");
64
+ document.addEventListener("turbo:before-render", function (e) {
65
+ var hasRail = !!e.detail.newBody.querySelector('[data-controller~="icon-rail"]');
66
+ if (hasRail && localStorage.getItem("pu_rail_pinned") !== "false") {
67
+ document.documentElement.classList.add("pu-rail-pinned");
68
+ } else {
69
+ document.documentElement.classList.remove("pu-rail-pinned");
71
70
  }
72
71
  });
73
72
  } catch (e) {}
@@ -4,6 +4,24 @@ module Plutonium
4
4
  class ResourceLayout < Base
5
5
  private
6
6
 
7
+ # Sets pu-rail-pinned immediately on initial page load so the rail
8
+ # renders in its pinned state from the first frame. Turbo navigations
9
+ # are handled by the turbo:before-render listener in Base.
10
+ def render_pre_paint_scripts
11
+ super
12
+ script do
13
+ raw(safe(<<~JS))
14
+ (function () {
15
+ try {
16
+ if (localStorage.getItem("pu_rail_pinned") !== "false") {
17
+ document.documentElement.classList.add("pu-rail-pinned");
18
+ }
19
+ } catch (e) {}
20
+ })();
21
+ JS
22
+ end
23
+ end
24
+
7
25
  def main_attributes
8
26
  classes = case Plutonium.configuration.shell
9
27
  when :modern
@@ -8,7 +8,7 @@ module Plutonium
8
8
  # inline search, and column config / overflow icon buttons into a single
9
9
  # tight strip rendered above the table when shell == :modern.
10
10
  class Toolbar < Plutonium::UI::Component::Base
11
- def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/")
11
+ def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/", export: nil)
12
12
  @query = query
13
13
  @search_url = search_url
14
14
  @search_param = search_param
@@ -17,10 +17,11 @@ module Plutonium
17
17
  @current_view = current_view
18
18
  @view_cookie_name = view_cookie_name
19
19
  @view_cookie_path = view_cookie_path
20
+ @export = export
20
21
  end
21
22
 
22
23
  def render?
23
- @views.size > 1 || has_filters? || has_search?
24
+ @views.size > 1 || has_filters? || has_search? || @export.present?
24
25
  end
25
26
 
26
27
  def view_template
@@ -29,11 +30,17 @@ module Plutonium
29
30
  render switcher
30
31
  render_divider if switcher.render?
31
32
  render_filter_button
33
+ render_export_button if @export
32
34
  div(class: "flex-1")
33
35
  render_search if has_search?
34
36
  end
35
37
  end
36
38
 
39
+ # Export split button, rendered just after the Filter button.
40
+ def render_export_button
41
+ render Plutonium::UI::ExportButton.new(**@export)
42
+ end
43
+
37
44
  private
38
45
 
39
46
  def has_filters?
@@ -47,10 +47,27 @@ module Plutonium
47
47
  views: resource_definition.defined_index_views,
48
48
  current_view: :table,
49
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)
50
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request),
51
+ export: export_toolbar_config
51
52
  )
52
53
  end
53
54
 
55
+ # Export split-button config, or nil when the policy forbids export.
56
+ # The primary link carries the current query (selected scope + filters
57
+ # + search); "Export all" carries `?all=1`.
58
+ def export_toolbar_config
59
+ return nil unless current_policy.allowed_to?(:export_csv?)
60
+
61
+ {
62
+ scoped_url: resource_url_for(resource_class, action: :export_csv, **export_query_params),
63
+ all_url: resource_url_for(resource_class, action: :export_csv, all: 1)
64
+ }
65
+ end
66
+
67
+ def export_query_params
68
+ params[:q].present? ? {q: params[:q].to_unsafe_h} : {}
69
+ end
70
+
54
71
  def render_filter_pills
55
72
  TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
56
73
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.58.0"
2
+ VERSION = "0.59.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.58.0",
3
+ "version": "0.59.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",
data/plutonium.gemspec CHANGED
@@ -51,6 +51,7 @@ Gem::Specification.new do |spec|
51
51
 
52
52
  spec.add_dependency "zeitwerk"
53
53
  spec.add_dependency "rails", ">= 7.2"
54
+ spec.add_dependency "csv" # CSV export; no longer a default gem on Ruby 3.4+
54
55
  spec.add_dependency "listen", "~> 3.8"
55
56
  spec.add_dependency "pagy", "~> 43.0"
56
57
  spec.add_dependency "rabl", "~> 0.17.0" # TODO: what to do with RABL
@@ -689,16 +689,16 @@ aside[data-controller~="icon-rail"] {
689
689
  }
690
690
 
691
691
  /* Pinned mode: rail expands, labels appear */
692
- body.pu-rail-pinned aside[data-controller~="icon-rail"] {
692
+ html.pu-rail-pinned aside[data-controller~="icon-rail"] {
693
693
  width: 14rem !important; /* 224px */
694
694
  }
695
695
 
696
- body.pu-rail-pinned .icon-rail-label {
696
+ html.pu-rail-pinned .icon-rail-label {
697
697
  display: inline-flex !important;
698
698
  }
699
699
 
700
- body.pu-rail-pinned .icon-rail-leaf,
701
- body.pu-rail-pinned .icon-rail-parent-trigger {
700
+ html.pu-rail-pinned .icon-rail-leaf,
701
+ html.pu-rail-pinned .icon-rail-parent-trigger {
702
702
  width: 100%;
703
703
  justify-content: flex-start;
704
704
  gap: 8px;
@@ -707,7 +707,7 @@ body.pu-rail-pinned .icon-rail-parent-trigger {
707
707
 
708
708
  /* When pinned, chevron flows inline after the label (right-aligned)
709
709
  and rotates to point down — mirrors a typical disclosure caret. */
710
- body.pu-rail-pinned .icon-rail-chevron {
710
+ html.pu-rail-pinned .icon-rail-chevron {
711
711
  position: static;
712
712
  background: transparent;
713
713
  box-shadow: none;
@@ -720,7 +720,7 @@ body.pu-rail-pinned .icon-rail-chevron {
720
720
  }
721
721
 
722
722
  /* When pinned, items stretch full width so labels read left-to-right */
723
- body.pu-rail-pinned #sidebar-navigation-content {
723
+ html.pu-rail-pinned #sidebar-navigation-content {
724
724
  align-items: stretch;
725
725
  padding-left: 8px;
726
726
  padding-right: 8px;
@@ -728,31 +728,31 @@ body.pu-rail-pinned #sidebar-navigation-content {
728
728
 
729
729
  /* When pinned, brand area aligns to the start so it reads as part of the
730
730
  left-aligned column instead of staying centered while items go left. */
731
- body.pu-rail-pinned aside[data-controller~="icon-rail"] > div:first-child {
731
+ html.pu-rail-pinned aside[data-controller~="icon-rail"] > div:first-child {
732
732
  justify-content: flex-start;
733
733
  padding-left: 14px;
734
734
  }
735
735
 
736
736
  /* When pinned, pin button right-aligns to anchor the collapse affordance
737
737
  to the rail edge it'll snap toward. */
738
- body.pu-rail-pinned aside[data-controller~="icon-rail"] > div:last-child {
738
+ html.pu-rail-pinned aside[data-controller~="icon-rail"] > div:last-child {
739
739
  justify-content: flex-end;
740
740
  padding-right: 8px;
741
741
  }
742
742
 
743
743
  /* Pin button icon swap */
744
- body.pu-rail-pinned .icon-rail-pin-collapse {
744
+ html.pu-rail-pinned .icon-rail-pin-collapse {
745
745
  display: inline-flex !important;
746
746
  }
747
747
 
748
- body.pu-rail-pinned .icon-rail-pin-expand {
748
+ html.pu-rail-pinned .icon-rail-pin-expand {
749
749
  display: none !important;
750
750
  }
751
751
 
752
752
  /* Main content padding when pinned: rail width + 1.5rem breathing room
753
753
  (matches the collapsed-state lg:pl-20 = rail 3.5rem + 1.5rem gap). */
754
754
  @media (min-width: 1024px) {
755
- body.pu-rail-pinned main {
755
+ html.pu-rail-pinned main {
756
756
  padding-left: 15.5rem !important;
757
757
  }
758
758
  }
@@ -11,11 +11,20 @@ export default class extends Controller {
11
11
  connect() {
12
12
  // Pinned is the default; only an explicit "false" collapses the rail.
13
13
  const pinned = localStorage.getItem(this.storageKeyValue) !== "false"
14
- document.body.classList.toggle("pu-rail-pinned", pinned)
14
+ document.documentElement.classList.toggle("pu-rail-pinned", pinned)
15
+ }
16
+
17
+ disconnect() {
18
+ // Guard: if another icon-rail is already in the DOM (Turbo swapped to a
19
+ // page that also has a rail), leave the class alone — the new controller's
20
+ // connect() will assert the correct value immediately after.
21
+ if (!document.querySelector('[data-controller~="icon-rail"]')) {
22
+ document.documentElement.classList.remove("pu-rail-pinned")
23
+ }
15
24
  }
16
25
 
17
26
  togglePin() {
18
- const pinned = document.body.classList.toggle("pu-rail-pinned")
27
+ const pinned = document.documentElement.classList.toggle("pu-rail-pinned")
19
28
  localStorage.setItem(this.storageKeyValue, pinned)
20
29
  }
21
30
  }
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.58.0
4
+ version: 0.59.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-10 00:00:00.000000000 Z
10
+ date: 2026-06-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: csv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: listen
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -641,6 +655,7 @@ files:
641
655
  - docs/reference/index.md
642
656
  - docs/reference/resource/actions.md
643
657
  - docs/reference/resource/definition.md
658
+ - docs/reference/resource/export.md
644
659
  - docs/reference/resource/index.md
645
660
  - docs/reference/resource/model.md
646
661
  - docs/reference/resource/query.md
@@ -680,6 +695,7 @@ files:
680
695
  - docs/superpowers/specs/2026-05-29-avatar-component-design.md
681
696
  - docs/superpowers/specs/2026-06-01-structured-inputs-design.md
682
697
  - docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md
698
+ - docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md
683
699
  - esbuild.config.js
684
700
  - exe/pug
685
701
  - gemfiles/rails_7.gemfile
@@ -1045,6 +1061,7 @@ files:
1045
1061
  - lib/plutonium/resource/controllers/crud_actions.rb
1046
1062
  - lib/plutonium/resource/controllers/crud_actions/index_action.rb
1047
1063
  - lib/plutonium/resource/controllers/defineable.rb
1064
+ - lib/plutonium/resource/controllers/export_csv.rb
1048
1065
  - lib/plutonium/resource/controllers/interactive_actions.rb
1049
1066
  - lib/plutonium/resource/controllers/presentable.rb
1050
1067
  - lib/plutonium/resource/controllers/queryable.rb
@@ -1105,6 +1122,7 @@ files:
1105
1122
  - lib/plutonium/ui/dyna_frame/content.rb
1106
1123
  - lib/plutonium/ui/dyna_frame/host.rb
1107
1124
  - lib/plutonium/ui/empty_card.rb
1125
+ - lib/plutonium/ui/export_button.rb
1108
1126
  - lib/plutonium/ui/form/base.rb
1109
1127
  - lib/plutonium/ui/form/components/easymde.rb
1110
1128
  - lib/plutonium/ui/form/components/flatpickr.rb