plutonium 0.59.0 → 0.60.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-auth/SKILL.md +8 -2
  3. data/.claude/skills/plutonium-ui/SKILL.md +12 -0
  4. data/CHANGELOG.md +15 -0
  5. data/app/assets/plutonium.css +1 -1
  6. data/docs/reference/auth/accounts.md +7 -0
  7. data/docs/reference/configuration.md +1 -1
  8. data/docs/reference/resource/definition.md +129 -0
  9. data/docs/reference/ui/forms.md +51 -21
  10. data/docs/reference/ui/layouts.md +37 -1
  11. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +926 -0
  12. data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
  13. data/docs/superpowers/plans/2026-06-14-railless-portal.md +761 -0
  14. data/docs/superpowers/plans/2026-06-14-railless-portal.md.tasks.json +51 -0
  15. data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +247 -0
  16. data/docs/superpowers/specs/2026-06-14-railless-portal-design.md +275 -0
  17. data/gemfiles/rails_7.gemfile.lock +1 -1
  18. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  19. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  20. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  21. data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
  22. data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
  23. data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
  24. data/lib/generators/pu/rodauth/views_generator.rb +1 -1
  25. data/lib/plutonium/auth/rodauth.rb +2 -1
  26. data/lib/plutonium/configuration.rb +2 -1
  27. data/lib/plutonium/core/controller.rb +19 -0
  28. data/lib/plutonium/definition/base.rb +1 -0
  29. data/lib/plutonium/definition/form_layout.rb +143 -0
  30. data/lib/plutonium/interaction/base.rb +1 -0
  31. data/lib/plutonium/package/engine.rb +17 -7
  32. data/lib/plutonium/ui/form/components/section.rb +58 -0
  33. data/lib/plutonium/ui/form/components/sticky_footer.rb +1 -1
  34. data/lib/plutonium/ui/form/resource.rb +85 -7
  35. data/lib/plutonium/ui/layout/base.rb +5 -0
  36. data/lib/plutonium/ui/layout/resource_layout.rb +22 -6
  37. data/lib/plutonium/ui/layout/topbar.rb +1 -1
  38. data/lib/plutonium/version.rb +1 -1
  39. data/package.json +1 -1
  40. data/src/css/components.css +9 -0
  41. data/src/css/slim_select.css +11 -2
  42. metadata +11 -2
@@ -13,7 +13,7 @@ module Pu
13
13
  desc "Generate views for selected features\n\n" \
14
14
  "Supported Features\n" \
15
15
  "=========================================\n" \
16
- "#{VIEW_CONFIG.keys.sort.map(&:to_s).join "\n"}\n\n\n\n"
16
+ "#{VIEW_CONFIG.keys.sort.join "\n"}\n\n\n\n"
17
17
 
18
18
  argument :plugin_name, type: :string, optional: true,
19
19
  desc: "[CONFIG] Name of the configured rodauth app. Leave blank to use the primary account."
@@ -7,7 +7,7 @@ module Plutonium
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- helper_method :current_user
10
+ helper_method :current_user, :current_#{name}
11
11
  helper_method :logout_url
12
12
  helper_method :profile_url
13
13
  end
@@ -23,6 +23,7 @@ module Plutonium
23
23
  def current_user
24
24
  rodauth.rails_account
25
25
  end
26
+ alias_method :current_#{name}, :current_user
26
27
 
27
28
  def logout_url
28
29
  rodauth.logout_path
@@ -27,7 +27,8 @@ module Plutonium
27
27
  # @return [Float] the current defaults version
28
28
  attr_reader :defaults_version
29
29
 
30
- # @return [Symbol] :classic (legacy Header/Sidebar) or :modern (Topbar/IconRail)
30
+ # @return [Symbol] :modern (Topbar/IconRail, default), :plain (Topbar, no
31
+ # icon rail), or :classic (legacy Header/Sidebar).
31
32
  attr_accessor :shell
32
33
 
33
34
  # @return [String] host URL of the Navii avatar service (no path), used by
@@ -45,6 +45,25 @@ module Plutonium
45
45
  append_view_path File.expand_path("app/views", Plutonium.root)
46
46
  layout -> { turbo_frame_request? ? false : "resource" }
47
47
  helper_method :registered_resources
48
+
49
+ class_attribute :_rail_enabled, instance_writer: false, default: nil
50
+ helper_method :rail?
51
+ end
52
+
53
+ class_methods do
54
+ # Enable or disable the modern icon rail for this controller and its
55
+ # subclasses. nil (the default) inherits the global shell default.
56
+ def rail(enabled)
57
+ self._rail_enabled = enabled
58
+ end
59
+ end
60
+
61
+ # Whether the modern icon rail is active for this request. Resolves the
62
+ # per-controller `rail` setting, falling back to the shell default.
63
+ # Public: the resource layout calls `controller.rail?`.
64
+ def rail?
65
+ return _rail_enabled unless _rail_enabled.nil?
66
+ Plutonium.configuration.shell == :modern
48
67
  end
49
68
 
50
69
  private
@@ -34,6 +34,7 @@ module Plutonium
34
34
  include Search
35
35
  include NestedInputs
36
36
  include StructuredInputs
37
+ include FormLayout
37
38
  include IndexViews
38
39
  include Metadata
39
40
 
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Declarative form sectioning. Mixed into both resource definitions and
6
+ # interactions (mirrors StructuredInputs). The layout references field KEYS
7
+ # only and carries section-level options; per-field config stays on `input`.
8
+ #
9
+ # @example
10
+ # form_layout do
11
+ # section :identity, :name, :email, label: "Your identification"
12
+ # section :address, :street, :city, collapsible: true, columns: 2,
13
+ # condition: -> { object.requires_address? }
14
+ # ungrouped label: "Other"
15
+ # end
16
+ module FormLayout
17
+ extend ActiveSupport::Concern
18
+
19
+ UNGROUPED_KEY = :ungrouped
20
+
21
+ # One declared section, or the implicit `ungrouped` bucket (empty `fields`).
22
+ Section = Struct.new(:key, :fields, :options) do
23
+ def ungrouped? = key == UNGROUPED_KEY
24
+ def label = options[:label] || key.to_s.humanize
25
+ def description = options[:description]
26
+ def collapsible? = !!options[:collapsible]
27
+ def collapsed? = !!options[:collapsed]
28
+ def columns = options[:columns]
29
+ def condition = options[:condition]
30
+ end
31
+
32
+ # A section paired with the concrete fields it will render (after policy
33
+ # filtering). Produced by #resolve_form_sections (a later task).
34
+ ResolvedSection = Struct.new(:section, :fields)
35
+
36
+ # Collects section/ungrouped calls from a form_layout block in order.
37
+ class Builder
38
+ attr_reader :sections
39
+
40
+ def initialize
41
+ @sections = []
42
+ @ungrouped_seen = false
43
+ end
44
+
45
+ def section(key, *fields, **options)
46
+ if key == UNGROUPED_KEY
47
+ raise ArgumentError,
48
+ "`section :#{UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
49
+ end
50
+ validate_columns!(options)
51
+ @sections << Section.new(key:, fields: fields.freeze, options: options.freeze)
52
+ end
53
+
54
+ def ungrouped(**options)
55
+ raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
56
+ @ungrouped_seen = true
57
+ validate_columns!(options)
58
+ @sections << Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: options.freeze)
59
+ end
60
+
61
+ private
62
+
63
+ def validate_columns!(options)
64
+ return unless options.key?(:columns)
65
+ value = options[:columns]
66
+ unless Integer === value && value > 0
67
+ raise ArgumentError,
68
+ "form_layout :columns must be a positive Integer, got #{value.inspect}"
69
+ end
70
+ end
71
+ end
72
+
73
+ class_methods do
74
+ # Declare the form layout. Re-declaring replaces it as a unit.
75
+ def form_layout(&block)
76
+ raise ArgumentError, "`form_layout` requires a block" unless block
77
+ builder = Builder.new
78
+ builder.instance_exec(&block)
79
+ @defined_form_layout = builder.sections.freeze
80
+ end
81
+
82
+ # Ordered Array<Section>, or nil when no layout was declared.
83
+ def defined_form_layout
84
+ @defined_form_layout
85
+ end
86
+
87
+ def inherited(subclass)
88
+ super
89
+ subclass.instance_variable_set(:@defined_form_layout, defined_form_layout&.dup)
90
+ end
91
+ end
92
+
93
+ # Instance access — the form render path holds a definition/interaction
94
+ # instance (mirrors the defineable_prop convention).
95
+ def defined_form_layout
96
+ self.class.defined_form_layout
97
+ end
98
+
99
+ # Resolve the policy-filtered field list into ordered ResolvedSections.
100
+ # Returns nil when no layout is declared (caller falls back to one grid).
101
+ def resolve_form_sections(resource_fields)
102
+ layout = defined_form_layout
103
+ return nil unless layout
104
+
105
+ resource_fields = resource_fields.map(&:to_sym)
106
+ known = resource_fields.to_set
107
+
108
+ # First-section-wins assignment: each field is claimed by the first
109
+ # section that lists it. A field a section lists but that isn't in the
110
+ # currently-permitted set (policy, per-action, entity scoping, nesting)
111
+ # is simply skipped — it never renders and is never an error.
112
+ owner = {}
113
+ layout.each do |section|
114
+ next if section.ungrouped?
115
+ section.fields.each do |f|
116
+ owner[f] ||= section.key if known.include?(f)
117
+ end
118
+ end
119
+ leftovers = resource_fields.reject { |f| owner.key?(f) }
120
+
121
+ resolved = layout.map do |section|
122
+ fields =
123
+ if section.ungrouped?
124
+ leftovers
125
+ else
126
+ section.fields.select { |f| owner[f] == section.key }
127
+ end
128
+ ResolvedSection.new(section:, fields:)
129
+ end
130
+
131
+ unless layout.any?(&:ungrouped?)
132
+ implicit = ResolvedSection.new(
133
+ section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
134
+ fields: leftovers
135
+ )
136
+ resolved.push(implicit)
137
+ end
138
+
139
+ resolved
140
+ end
141
+ end
142
+ end
143
+ end
@@ -26,6 +26,7 @@ module Plutonium
26
26
  include Plutonium::Definition::ConfigAttr
27
27
  include Plutonium::Definition::Presentable
28
28
  include Plutonium::Definition::StructuredInputs
29
+ include Plutonium::Definition::FormLayout
29
30
 
30
31
  # On interactions, declaring a structured input also declares the backing
31
32
  # ActiveModel attribute so the value survives `attributes=` and appears in
@@ -4,14 +4,24 @@ module Plutonium
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  included do
7
- # prevent this package from being added to the view lookup
8
- # since we need finer control over how views are resolved.
9
- # view lookup configuration is handled at the controller level
10
- config.before_configuration do
11
- # this touches the internals of rails, but I could not find a good way of doing this
12
- # we get the initializer instance and set the block property to a noop
7
+ # Prevent this package's app/views from being appended to the global
8
+ # ActionController/ActionMailer view lookup Plutonium resolves package
9
+ # views at the controller level (see Plutonium::Core::Controllers::Bootable,
10
+ # which reads current_engine.paths["app/views"]). We neutralize the
11
+ # engine's built-in `add_view_paths` initializer rather than clearing
12
+ # config.paths["app/views"], which that controller-level resolver needs.
13
+ #
14
+ # This MUST run as a real initializer (before :add_view_paths), NOT in
15
+ # before_configuration: that hook can fire before sibling package engines
16
+ # are loaded (it does in development, where :before_configuration has
17
+ # already run by the time config/packages.rb loads). Touching
18
+ # Rails.application.initializers there forces Rails.application.railties
19
+ # to memoize early — with only the packages loaded so far — permanently
20
+ # dropping the rest from the autoload paths (e.g. `uninitialized constant
21
+ # Blogging::Post`). By initializer-run time, railties is fully populated.
22
+ initializer :plutonium_neutralize_add_view_paths, before: :add_view_paths do
13
23
  add_view_paths_initializer = Rails.application.initializers.find do |a|
14
- a.context_class == self && a.name.to_s == "add_view_paths"
24
+ a.context_class == self.class && a.name.to_s == "add_view_paths"
15
25
  end
16
26
  add_view_paths_initializer&.instance_variable_set(:@block, ->(app) {})
17
27
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Renders a form section's chrome (heading/description, optional native
8
+ # <details> collapsible, and a fields grid) and yields to a block that
9
+ # renders the section's fields (the form supplies render_resource_field).
10
+ class Section < Plutonium::UI::Component::Base
11
+ def initialize(resolved, grid_class:)
12
+ @section = resolved.section
13
+ @grid_class = grid_class
14
+ end
15
+
16
+ SECTION_CLASS = "space-y-4 border-t border-[var(--pu-border-muted)] pt-6 first:border-t-0 first:pt-0"
17
+ HEADING_CLASS = "text-base font-semibold text-[var(--pu-text)]"
18
+ SUMMARY_CLASS = "#{HEADING_CLASS} cursor-pointer select-none"
19
+ DESCRIPTION_CLASS = "text-sm text-[var(--pu-text-muted)]"
20
+
21
+ def view_template(&fields_block)
22
+ if @section.collapsible?
23
+ details(open: !@section.collapsed?, class: SECTION_CLASS) do
24
+ summary(class: SUMMARY_CLASS) { heading_text }
25
+ describe
26
+ grid(&fields_block)
27
+ end
28
+ else
29
+ div(class: SECTION_CLASS) do
30
+ header_block
31
+ grid(&fields_block)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def header_block
39
+ return if @section.ungrouped? && @section.options[:label].nil?
40
+ h3(class: HEADING_CLASS) { heading_text }
41
+ describe
42
+ end
43
+
44
+ def heading_text = @section.label
45
+
46
+ def describe
47
+ return unless @section.description
48
+ p(class: DESCRIPTION_CLASS) { @section.description }
49
+ end
50
+
51
+ def grid(&fields_block)
52
+ div(class: @grid_class, &fields_block)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -6,7 +6,7 @@ module Plutonium
6
6
  module Components
7
7
  class StickyFooter < Plutonium::UI::Component::Base
8
8
  def view_template(&block)
9
- div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
9
+ div(class: "pu-sticky-footer fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
10
10
  "h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
11
11
  "px-6 flex items-center justify-end gap-2", &block)
12
12
  end
@@ -98,11 +98,81 @@ module Plutonium
98
98
  end
99
99
 
100
100
  def render_fields
101
- fields_wrapper {
102
- resource_fields.each { |name|
103
- render_resource_field name
101
+ sections = resolve_form_layout
102
+ if sections.nil?
103
+ fields_wrapper {
104
+ resource_fields.each { |name| render_resource_field name }
104
105
  }
105
- }
106
+ else
107
+ sections.each { |rs| render_form_section(rs) }
108
+ end
109
+ end
110
+
111
+ # Resolve the whole form layout for THIS render, in one pass: drop
112
+ # condition-hidden sections and evaluate any proc-valued options, all in
113
+ # the form instance context (where `object`, `current_user`, `params` and
114
+ # helpers live — same context as input/section `condition:`). Returns nil
115
+ # when no form_layout is declared (caller falls back to a single grid).
116
+ def resolve_form_layout
117
+ sections = resource_definition.resolve_form_sections(resource_fields)
118
+ return nil if sections.nil?
119
+
120
+ sections.filter_map do |resolved|
121
+ section = resolved.section
122
+ condition = section.condition
123
+ next if condition && !instance_exec(&condition)
124
+
125
+ # `columns` stays a validated literal; everything else may be a proc.
126
+ options = section.options.to_h do |key, value|
127
+ [key, (key != :condition && value.is_a?(Proc)) ? instance_exec(&value) : value]
128
+ end
129
+ Plutonium::Definition::FormLayout::ResolvedSection.new(
130
+ section: Plutonium::Definition::FormLayout::Section.new(
131
+ key: section.key, fields: section.fields, options: options.freeze
132
+ ),
133
+ fields: resolved.fields
134
+ )
135
+ end
136
+ end
137
+
138
+ # Pure presentation — the section is already resolved (visible, options
139
+ # evaluated) by resolve_form_layout.
140
+ def render_form_section(resolved)
141
+ section = resolved.section
142
+ render Plutonium::UI::Form::Components::Section.new(
143
+ resolved, grid_class: section_grid_class(section.columns)
144
+ ) do
145
+ # Inside a multi-column section, let fields flow into the grid cells
146
+ # instead of forcing each to span the full row (see col-span default
147
+ # in render_simple_resource_field).
148
+ previous = @section_columns
149
+ @section_columns = section.columns
150
+ begin
151
+ resolved.fields.each { |name| render_resource_field name }
152
+ ensure
153
+ @section_columns = previous
154
+ end
155
+ end
156
+ end
157
+
158
+ # True while rendering fields inside a section that declared an explicit
159
+ # column count > 1. Such fields default to a single grid cell rather than
160
+ # `col-span-full`, so the section's grid actually lays out in columns.
161
+ def in_multi_column_section?
162
+ @section_columns.to_i > 1
163
+ end
164
+
165
+ # nil → the form's default responsive grid; an Integer overrides columns.
166
+ def section_grid_class(columns)
167
+ return themed(:fields_wrapper, nil) if columns.nil?
168
+
169
+ base = "grid gap-6 grid-flow-row-dense grid-cols-1"
170
+ case columns.to_i
171
+ when 1 then base
172
+ when 2 then "#{base} md:grid-cols-2"
173
+ when 3 then "#{base} md:grid-cols-2 lg:grid-cols-3"
174
+ else "#{base} md:grid-cols-2 2xl:grid-cols-#{columns.to_i}"
175
+ end
106
176
  end
107
177
 
108
178
  def render_actions
@@ -232,10 +302,18 @@ module Plutonium
232
302
  end
233
303
  else
234
304
  wrapper_options = input_options[:wrapper] || {}
305
+ # Only supply a default column span when the field hasn't declared its
306
+ # own (via `wrapper: {class: "col-span-..."}`). A field-level col-span
307
+ # ALWAYS wins — including inside a section with `columns:` — so authors
308
+ # can opt a single field back to full width in a multi-column section,
309
+ # or vice versa.
310
+ # TODO: remove the string check once theming supports class merges.
235
311
  if !wrapper_options[:class] || !wrapper_options[:class].include?("col-span")
236
- # temp hack to allow col span overrides
237
- # TODO: remove once we complete theming, which will support merges
238
- wrapper_options[:class] = tokens("col-span-full", wrapper_options[:class])
312
+ # In a multi-column section the field flows into a single grid cell
313
+ # (no col-span), so the declared `columns:` actually takes effect.
314
+ # Everywhere else fields span the full row.
315
+ default_span = in_multi_column_section? ? nil : "col-span-full"
316
+ wrapper_options[:class] = tokens(default_span, wrapper_options[:class])
239
317
  end
240
318
 
241
319
  render form.field(name, **field_options).wrapped(
@@ -48,7 +48,11 @@ module Plutonium
48
48
  # on <html> based on whether the incoming body contains an icon-rail,
49
49
  # preventing layout shift on Turbo navigations between rail and non-rail
50
50
  # pages. Initial-load rail-pinned is handled by ResourceLayout.
51
+ # The same listener also keeps `pu-no-rail` in sync (inverse of the rail
52
+ # signal) for non-classic shells, since Turbo Drive does not update
53
+ # <html> attributes across cross-URL visits.
51
54
  def render_pre_paint_scripts
55
+ manage_no_rail = Plutonium.configuration.shell != :classic
52
56
  script do
53
57
  raw(safe(<<~JS))
54
58
  (function () {
@@ -68,6 +72,7 @@ module Plutonium
68
72
  } else {
69
73
  document.documentElement.classList.remove("pu-rail-pinned");
70
74
  }
75
+ #{'document.documentElement.classList.toggle("pu-no-rail", !hasRail);' if manage_no_rail}
71
76
  });
72
77
  } catch (e) {}
73
78
  })();
@@ -4,11 +4,18 @@ module Plutonium
4
4
  class ResourceLayout < Base
5
5
  private
6
6
 
7
+ # Whether the modern icon rail is active for this request. Delegates to
8
+ # the controller's resolution (shell default + per-controller `rail`).
9
+ def rail? = controller.rail?
10
+
7
11
  # Sets pu-rail-pinned immediately on initial page load so the rail
8
12
  # renders in its pinned state from the first frame. Turbo navigations
9
- # are handled by the turbo:before-render listener in Base.
13
+ # are handled by the turbo:before-render listener in Base. Skipped
14
+ # entirely when the layout renders no rail.
10
15
  def render_pre_paint_scripts
11
16
  super
17
+ return unless rail?
18
+
12
19
  script do
13
20
  raw(safe(<<~JS))
14
21
  (function () {
@@ -22,12 +29,21 @@ module Plutonium
22
29
  end
23
30
  end
24
31
 
32
+ # Adds `pu-no-rail` to <html> (server-side, no FOUC) so decoupled fixed
33
+ # chrome (Topbar, form StickyFooter) can cancel their rail insets.
34
+ # Scoped to the modern family — :classic keeps its own offsets.
35
+ def html_attributes
36
+ attrs = super
37
+ return attrs if Plutonium.configuration.shell == :classic
38
+
39
+ rail? ? attrs : mix(attrs, {class: "pu-no-rail"})
40
+ end
41
+
25
42
  def main_attributes
26
- classes = case Plutonium.configuration.shell
27
- when :modern
28
- "pt-16 pb-6 px-6 lg:pl-20"
29
- else
43
+ classes = if Plutonium.configuration.shell == :classic
30
44
  "pt-20 lg:ml-64"
45
+ else
46
+ rail? ? "pt-16 pb-6 px-6 lg:pl-20" : "pt-16 pb-6 px-6"
31
47
  end
32
48
 
33
49
  mix(super, {class: classes})
@@ -43,7 +59,7 @@ module Plutonium
43
59
  super
44
60
 
45
61
  render partial("resource_header")
46
- render partial("resource_sidebar")
62
+ render partial("resource_sidebar") if rail?
47
63
  end
48
64
  end
49
65
  end
@@ -33,7 +33,7 @@ module Plutonium
33
33
 
34
34
  def view_template
35
35
  nav(
36
- class: "fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
36
+ class: "pu-topbar fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
37
37
  "bg-[var(--pu-surface)] border-b border-[var(--pu-border)] " \
38
38
  "flex items-center gap-3 px-4",
39
39
  data: {
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.59.0"
2
+ VERSION = "0.60.1"
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.59.0",
3
+ "version": "0.60.1",
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",
@@ -757,6 +757,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
757
757
  }
758
758
  }
759
759
 
760
+ /* Rail-less layouts (html.pu-no-rail): cancel the icon-rail inset on fixed
761
+ chrome so the topbar and form sticky footer span the full width. */
762
+ @media (min-width: 1024px) {
763
+ html.pu-no-rail .pu-topbar,
764
+ html.pu-no-rail .pu-sticky-footer {
765
+ left: 0 !important;
766
+ }
767
+ }
768
+
760
769
  /* Flyout: visibility controlled by Stimulus (data-flyout-open) via position:fixed */
761
770
  .icon-rail-parent {
762
771
  position: relative;
@@ -43,9 +43,11 @@
43
43
  border-radius: var(--pu-radius-md);
44
44
  color: var(--pu-text);
45
45
  }
46
+
46
47
  .ss-main:hover {
47
48
  border-color: var(--pu-border-strong);
48
49
  }
50
+
49
51
  .ss-main:focus,
50
52
  .ss-main[aria-expanded="true"] {
51
53
  border-color: var(--pu-input-focus-ring);
@@ -196,9 +198,11 @@
196
198
  font-size: inherit;
197
199
  line-height: inherit;
198
200
  }
201
+
199
202
  .ss-content .ss-search input::placeholder {
200
203
  color: var(--pu-input-placeholder);
201
204
  }
205
+
202
206
  .ss-content .ss-search input:focus {
203
207
  border-color: var(--pu-input-focus-ring);
204
208
  box-shadow: 0 0 0 3px theme(colors.primary.500 / 15%);
@@ -225,7 +229,7 @@
225
229
 
226
230
  /* List */
227
231
  .ss-content .ss-list {
228
- @apply flex-auto h-auto overflow-x-hidden overflow-y-auto;
232
+ @apply flex-auto h-auto overflow-x-hidden overflow-y-auto mb-2;
229
233
  }
230
234
 
231
235
  .ss-content .ss-list .ss-error {
@@ -345,10 +349,12 @@
345
349
  .ss-main.ss-invalid {
346
350
  @apply border-danger-500 bg-danger-50/50 text-danger-900;
347
351
  }
352
+
348
353
  .ss-main.ss-invalid:focus,
349
354
  .ss-main.ss-invalid[aria-expanded="true"] {
350
355
  box-shadow: 0 0 0 3px theme(colors.danger.500 / 15%);
351
356
  }
357
+
352
358
  .dark .ss-main.ss-invalid {
353
359
  @apply bg-danger-950/20 border-danger-500/70 text-danger-200;
354
360
  }
@@ -360,10 +366,12 @@
360
366
  .ss-main.ss-valid {
361
367
  @apply border-success-500 bg-success-50/50 text-success-900;
362
368
  }
369
+
363
370
  .ss-main.ss-valid:focus,
364
371
  .ss-main.ss-valid[aria-expanded="true"] {
365
372
  box-shadow: 0 0 0 3px theme(colors.success.500 / 15%);
366
373
  }
374
+
367
375
  .dark .ss-main.ss-valid {
368
376
  @apply bg-success-950/20 border-success-500/70 text-success-200;
369
377
  }
@@ -385,7 +393,8 @@
385
393
  width: 100% !important;
386
394
  border-radius: 0 !important;
387
395
  margin: 0 !important;
388
- pointer-events: none !important; /* Disabled by default */
396
+ pointer-events: none !important;
397
+ /* Disabled by default */
389
398
  }
390
399
 
391
400
  /* When active (dropdown is expanded), enable pointer events */