plutonium 0.55.0 → 0.56.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +1 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +1 -1
  4. data/.claude/skills/plutonium-auth/SKILL.md +1 -1
  5. data/.claude/skills/plutonium-resource/SKILL.md +56 -3
  6. data/.claude/skills/plutonium-ui/SKILL.md +15 -2
  7. data/CHANGELOG.md +44 -0
  8. data/CONTRIBUTING.md +1 -1
  9. data/README.md +38 -16
  10. data/app/assets/plutonium.css +1 -1
  11. data/app/assets/plutonium.js +94 -26
  12. data/app/assets/plutonium.js.map +2 -2
  13. data/app/assets/plutonium.min.js +9 -9
  14. data/app/assets/plutonium.min.js.map +3 -3
  15. data/config/initializers/rabl.rb +16 -0
  16. data/docs/.vitepress/config.ts +1 -0
  17. data/docs/getting-started/installation.md +2 -2
  18. data/docs/getting-started/tutorial/02-first-resource.md +1 -1
  19. data/docs/getting-started/tutorial/03-authentication.md +3 -3
  20. data/docs/guides/adding-resources.md +1 -1
  21. data/docs/guides/authentication.md +1 -1
  22. data/docs/guides/creating-packages.md +1 -1
  23. data/docs/guides/multi-tenancy.md +1 -1
  24. data/docs/guides/nested-resources.md +1 -1
  25. data/docs/guides/user-invites.md +1 -1
  26. data/docs/guides/user-profile.md +1 -1
  27. data/docs/public/templates/lite.rb +10 -0
  28. data/docs/reference/app/generators.md +3 -3
  29. data/docs/reference/app/index.md +1 -1
  30. data/docs/reference/app/portals.md +1 -1
  31. data/docs/reference/auth/profile.md +1 -1
  32. data/docs/reference/generators/lite.md +65 -0
  33. data/docs/reference/resource/actions.md +55 -0
  34. data/docs/reference/resource/definition.md +18 -2
  35. data/docs/reference/resource/index.md +1 -1
  36. data/docs/reference/tenancy/invites.md +1 -1
  37. data/docs/reference/ui/assets.md +14 -0
  38. data/docs/reference/ui/displays.md +27 -1
  39. data/docs/reference/ui/forms.md +2 -1
  40. data/docs/reference/ui/layouts.md +33 -0
  41. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  42. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  43. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  44. data/gemfiles/rails_7.gemfile.lock +1 -1
  45. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  46. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  47. data/lib/generators/pu/core/typespec/typespec_generator.rb +1 -1
  48. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  49. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  50. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  51. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  52. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  53. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  54. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  55. data/lib/generators/pu/saas/welcome_generator.rb +1 -1
  56. data/lib/plutonium/action/base.rb +19 -2
  57. data/lib/plutonium/action/condition_context.rb +33 -0
  58. data/lib/plutonium/models/has_cents.rb +10 -0
  59. data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
  60. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  61. data/lib/plutonium/ui/display/base.rb +9 -0
  62. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  63. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  64. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  65. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  66. data/lib/plutonium/ui/display/theme.rb +5 -0
  67. data/lib/plutonium/ui/form/base.rb +5 -0
  68. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  69. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
  70. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  71. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
  72. data/lib/plutonium/ui/form/interaction.rb +7 -2
  73. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  74. data/lib/plutonium/ui/form/resource.rb +1 -0
  75. data/lib/plutonium/ui/form/theme.rb +12 -0
  76. data/lib/plutonium/ui/grid/card.rb +61 -23
  77. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  78. data/lib/plutonium/ui/page/index.rb +1 -1
  79. data/lib/plutonium/ui/page/show.rb +1 -1
  80. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  81. data/lib/plutonium/ui/table/resource.rb +2 -2
  82. data/lib/plutonium/version.rb +1 -1
  83. data/package.json +1 -1
  84. data/plutonium.gemspec +5 -4
  85. data/src/css/components.css +126 -0
  86. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  87. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  88. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  89. metadata +20 -6
@@ -115,45 +115,12 @@ module Plutonium
115
115
  div(class: FIELD_GRID_CLASS) do
116
116
  fields.each { |input| render_simple_resource_field(input, definition, nested) }
117
117
  end
118
- render_structured_remove_button
119
- end
120
- render_structured_removed_bar
121
- end
122
- end
123
-
124
- def render_structured_remove_button
125
- div(class: "flex items-center justify-end") do
126
- button(
127
- type: :button,
128
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
129
- "text-danger-700 hover:bg-danger-50 dark:text-danger-400 dark:hover:bg-danger-950/30 " \
130
- "focus:outline-none focus:ring-4 focus:ring-danger-200 dark:focus:ring-danger-900",
131
- data_action: "structured-input-row#remove"
132
- ) do
133
- render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
134
- span { "Remove" }
135
- end
136
- end
137
- end
138
-
139
- # Compact bar shown in place of the row once it's marked for removal.
140
- def render_structured_removed_bar
141
- div(
142
- data_structured_input_row_target: "removed",
143
- hidden: true,
144
- class: "flex items-center justify-between gap-3 text-sm text-[var(--pu-text-muted)]"
145
- ) do
146
- span(class: "italic") { "Removed" }
147
- button(
148
- type: :button,
149
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
150
- "text-secondary-700 hover:bg-secondary-50 dark:text-secondary-300 dark:hover:bg-secondary-900/30 " \
151
- "focus:outline-none focus:ring-4 focus:ring-secondary-200 dark:focus:ring-secondary-900",
152
- data_action: "structured-input-row#restore"
153
- ) do
154
- render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
155
- span { "Restore" }
118
+ render_repeater_remove_button(action: "structured-input-row#remove")
156
119
  end
120
+ render_repeater_removed_bar(
121
+ restore_action: "structured-input-row#restore",
122
+ data_structured_input_row_target: "removed"
123
+ )
157
124
  end
158
125
  end
159
126
 
@@ -27,8 +27,13 @@ module Plutonium
27
27
  )
28
28
  )
29
29
 
30
- # Use existing infrastructure to build the URL
31
- subject = action.record_action? ? resource_record! : resource_class
30
+ # Use existing infrastructure to build the URL.
31
+ # Record-level actions (shown on the show page AND/OR on collection rows)
32
+ # operate on a single record, so the commit URL must target the record.
33
+ # `record_action?` alone is wrong: a collection-row action can be
34
+ # surfaced with `record_action: false` while still being record-scoped.
35
+ record_scoped = action.record_action? || action.collection_record_action?
36
+ subject = record_scoped ? resource_record! : resource_class
32
37
  route_options_to_url(commit_route_options, subject)
33
38
  end
34
39
 
@@ -19,6 +19,8 @@ module Plutonium
19
19
  :slim_select
20
20
  when :date, :time, :datetime
21
21
  :flatpickr
22
+ when :boolean
23
+ :toggle
22
24
  else
23
25
  inferred_field_component
24
26
  end
@@ -4,6 +4,7 @@ module Plutonium
4
4
  module UI
5
5
  module Form
6
6
  class Resource < Base
7
+ include Plutonium::UI::Form::Concerns::RendersRepeaterRowControls
7
8
  include Plutonium::UI::Form::Concerns::RendersNestedResourceFields
8
9
  include Plutonium::UI::Form::Concerns::RendersStructuredInputs
9
10
 
@@ -39,6 +39,11 @@ module Plutonium
39
39
  valid_boolean: "pu-checkbox",
40
40
  invalid_boolean: "pu-checkbox pu-input-invalid",
41
41
 
42
+ # Toggle switch (opt-in boolean input: `as: :toggle`)
43
+ toggle: "pu-toggle",
44
+ valid_toggle: "pu-toggle",
45
+ invalid_toggle: "pu-toggle pu-input-invalid",
46
+
42
47
  # Radio buttons
43
48
  radio_button: "pu-radio",
44
49
  collection_radio_buttons: "flex flex-col gap-2",
@@ -81,6 +86,13 @@ module Plutonium
81
86
  invalid_int_tel_input: :invalid_input,
82
87
  neutral_int_tel_input: :neutral_input,
83
88
 
89
+ # JSON / JSONB textarea — without this the component falls back to
90
+ # the neutral (classless) theme and loses pu-input's dark styling.
91
+ json: :input,
92
+ valid_json: :valid_input,
93
+ invalid_json: :invalid_input,
94
+ neutral_json: :neutral_input,
95
+
84
96
  # Uppy file upload
85
97
  uppy: :file,
86
98
  valid_uppy: :valid_file,
@@ -100,46 +100,83 @@ module Plutonium
100
100
  end
101
101
 
102
102
  def render_subheader_slot
103
- value = field_value(slots[:subheader])
104
- return if value.blank?
105
- p(class: "text-xs text-[var(--pu-text-muted)] truncate") { plain helpers.display_name_of(value) }
103
+ name = slots[:subheader]
104
+ value = field_value(name)
105
+ p(class: "text-xs text-[var(--pu-text-muted)] truncate") do
106
+ value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
107
+ end
106
108
  end
107
109
 
108
110
  def render_body_slot
109
- value = field_value(slots[:body])
110
- return if value.blank?
111
- p(class: "text-sm text-[var(--pu-text)] line-clamp-3") { plain helpers.display_name_of(value) }
111
+ name = slots[:body]
112
+ value = field_value(name)
113
+ p(class: "text-sm text-[var(--pu-text)] line-clamp-3") do
114
+ value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
115
+ end
112
116
  end
113
117
 
114
118
  def render_meta_slot
115
119
  fields = Array(slots[:meta])
116
120
  values = fields.map { |f| field_value(f) }.reject(&:blank?)
117
- return if values.empty?
118
121
 
119
122
  div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
120
- values.each do |v|
121
- span(class: "inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium " \
122
- "bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]") do
123
- plain helpers.display_name_of(v)
124
- end
123
+ if values.empty?
124
+ render_blank_placeholder
125
+ else
126
+ values.each { |v| render_meta_badge(v) }
125
127
  end
126
128
  end
127
129
  end
128
130
 
129
131
  def render_footer_slot
130
- value = field_value(footer_field)
131
- return if value.blank?
132
+ name = footer_field
133
+ value = field_value(name)
132
134
  p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
133
- if value.respond_to?(:strftime)
134
- # display_datetime_value returns HTML-safe <time> markup
135
- # rendered by the timeago Stimulus controller.
136
- raw safe(helpers.display_datetime_value(value))
137
- else
138
- plain helpers.display_name_of(value)
139
- end
135
+ value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
136
+ end
137
+ end
138
+
139
+ # Emits a slot value formatted by type, reusing the display layer's
140
+ # logic without its show-page label/wrapper chrome:
141
+ # - dates/times → timeago markup (same path as the footer used)
142
+ # - has_cents columns → currency (matches the Currency component)
143
+ # - everything else → display_name_of
144
+ def render_formatted_value(name, value)
145
+ if value.respond_to?(:strftime)
146
+ # display_datetime_value returns HTML-safe <time> markup
147
+ # rendered by the timeago Stimulus controller.
148
+ raw safe(helpers.display_datetime_value(value))
149
+ elsif currency_field?(name)
150
+ plain helpers.number_to_currency(value, unit: "")
151
+ else
152
+ plain helpers.display_name_of(value)
153
+ end
154
+ end
155
+
156
+ # Renders a meta value as a colored pill, borrowing the Badge
157
+ # display component's semantic color + humanize logic. Status-like
158
+ # values (published, pending, failed…) get meaningful colors;
159
+ # free-form values get a deterministic decorative color.
160
+ def render_meta_badge(value)
161
+ badge = Plutonium::UI::Display::Components::Badge
162
+ variant = badge.variant_for(value)
163
+ span(class: tokens("pu-badge", "pu-badge-#{variant}")) do
164
+ plain badge.humanize(value)
140
165
  end
141
166
  end
142
167
 
168
+ def currency_field?(name)
169
+ klass = record.class
170
+ klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(name.to_sym)
171
+ end
172
+
173
+ # A declared slot with no value renders a muted em-dash rather than
174
+ # collapsing, so cards in a grid keep an even height instead of
175
+ # ragged rows when some records lack the field.
176
+ def render_blank_placeholder
177
+ span(class: "text-[var(--pu-text-subtle)]") { plain "—" }
178
+ end
179
+
143
180
  # ---------------------------------------------------------------
144
181
  # Card chrome — selection, actions, show
145
182
  # ---------------------------------------------------------------
@@ -195,12 +232,13 @@ module Plutonium
195
232
 
196
233
  def row_actions
197
234
  @row_actions ||= resource_definition.defined_actions.values.select { |a|
198
- a.collection_record_action? && a.permitted_by?(record_policy)
235
+ a.collection_record_action? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
199
236
  }
200
237
  end
201
238
 
202
239
  def can_show?
203
- resource_definition.defined_actions[:show]&.permitted_by?(record_policy)
240
+ action = resource_definition.defined_actions[:show]
241
+ action&.permitted_by?(record_policy) && action.condition_met?(view_context, record:)
204
242
  end
205
243
 
206
244
  def record_policy
@@ -113,8 +113,7 @@ module Plutonium
113
113
  a(
114
114
  href: item.url,
115
115
  title: item.label,
116
- aria: {label: item.label},
117
- class: "icon-rail-leaf #{leaf_classes(item, depth)}"
116
+ **item_link_attributes(item, "icon-rail-leaf #{leaf_classes(item, depth)}", base_aria: {label: item.label})
118
117
  ) do
119
118
  render_item_icon(item)
120
119
  span(class: "icon-rail-label hidden") { item.label }
@@ -137,12 +136,12 @@ module Plutonium
137
136
  a(
138
137
  href: item.url || "#",
139
138
  title: item.label,
140
- aria: {label: item.label, haspopup: "menu", expanded: "false"},
141
- data: {
142
- "icon-rail-flyout-target": "trigger",
143
- action: "click->icon-rail-flyout#toggle"
144
- },
145
- class: "icon-rail-parent-trigger #{parent_trigger_classes(item, depth)}"
139
+ **item_link_attributes(
140
+ item,
141
+ "icon-rail-parent-trigger #{parent_trigger_classes(item, depth)}",
142
+ base_aria: {label: item.label, haspopup: "menu", expanded: "false"},
143
+ base_data: {"icon-rail-flyout-target": "trigger", action: "click->icon-rail-flyout#toggle"}
144
+ )
146
145
  ) do
147
146
  render_item_icon(item)
148
147
  span(class: "icon-rail-label") { item.label }
@@ -159,7 +158,11 @@ module Plutonium
159
158
  div(class: "icon-rail-flyout-inner") do
160
159
  div(class: "icon-rail-flyout-label") { item.label }
161
160
  item.items.each do |child|
162
- a(href: child.url, class: "icon-rail-flyout-item", role: "menuitem") { child.label }
161
+ a(
162
+ href: child.url,
163
+ role: "menuitem",
164
+ **item_link_attributes(child, "icon-rail-flyout-item")
165
+ ) { child.label }
163
166
  end
164
167
  end
165
168
  end
@@ -206,6 +209,23 @@ module Plutonium
206
209
  def active?(item)
207
210
  item.active?(self)
208
211
  end
212
+
213
+ # Anchor attributes a menu item opts into via its Phlexi::Menu options
214
+ # (target:, rel:, data:, aria:, …), merged with the anchor's own
215
+ # framework attributes and spread onto the <a>. The framework's
216
+ # class / data / aria (base styling, flyout wiring, popup semantics)
217
+ # take precedence so a menu item can *extend* the link without breaking
218
+ # navigation behavior. Phlexi keeps its own :active key in options,
219
+ # which must never become an attribute.
220
+ def item_link_attributes(item, base_class, base_data: {}, base_aria: {})
221
+ opts = (item.options || {}).except(:active)
222
+ data = (opts[:data] || {}).merge(base_data)
223
+ aria = (opts[:aria] || {}).merge(base_aria)
224
+ opts[:class] = [base_class, opts[:class]].compact.join(" ")
225
+ opts[:data] = data unless data.empty?
226
+ opts[:aria] = aria unless aria.empty?
227
+ opts
228
+ end
209
229
  end
210
230
  end
211
231
  end
@@ -33,7 +33,7 @@ module Plutonium
33
33
  end
34
34
 
35
35
  def page_actions
36
- super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) }
36
+ super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
37
37
  end
38
38
 
39
39
  def render_default_content
@@ -15,7 +15,7 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def page_actions
18
- super || current_definition.defined_actions.values.select { |a| a.record_action? && a.permitted_by?(current_policy) }
18
+ super || current_definition.defined_actions.values.select { |a| a.record_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
19
19
  end
20
20
 
21
21
  def render_default_content
@@ -89,6 +89,35 @@ module Plutonium
89
89
  end
90
90
  end
91
91
 
92
+ # Spread any per-item HTML attributes (target:, rel:, data:, …) the item
93
+ # opts into via its Phlexi::Menu options — e.g. a menu item that opens a
94
+ # full-screen SPA in its own tab. The base Phlexi implementation
95
+ # hardcodes the anchor and drops these, so we re-render the leaf.
96
+ def render_item_link(item, depth)
97
+ link_class = themed(:item_link, depth)
98
+ active = active_class(item, depth)
99
+ classes = active ? "#{link_class} #{active}" : link_class
100
+
101
+ a(href: item.url, **item_link_attributes(item, classes)) do
102
+ render_item_interior(item, depth)
103
+ end
104
+ end
105
+
106
+ # Anchor attributes opted into via Phlexi::Menu item options (target:,
107
+ # rel:, data:, aria:, …), minus Phlexi's own :active key (which must not
108
+ # leak onto the <a>). A user-supplied :class merges with the themed base
109
+ # classes; base_data / base_aria (none on the plain leaf today) always
110
+ # win so options extend rather than replace framework wiring.
111
+ def item_link_attributes(item, base_class, base_data: {}, base_aria: {})
112
+ opts = (item.options || {}).except(:active)
113
+ data = (opts[:data] || {}).merge(base_data)
114
+ aria = (opts[:aria] || {}).merge(base_aria)
115
+ opts[:class] = [base_class, opts[:class]].compact.join(" ")
116
+ opts[:data] = data unless data.empty?
117
+ opts[:aria] = aria unless aria.empty?
118
+ opts
119
+ end
120
+
92
121
  def render_collapsible_button(item, depth)
93
122
  button(
94
123
  type: "button",
@@ -134,7 +134,7 @@ module Plutonium
134
134
  policy = policy_for(record:)
135
135
 
136
136
  actions = resource_definition.defined_actions
137
- .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") }
137
+ .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
138
138
  .values
139
139
 
140
140
  primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
@@ -161,7 +161,7 @@ module Plutonium
161
161
 
162
162
  def bulk_actions
163
163
  @bulk_actions ||= resource_definition.defined_actions
164
- .select { |k, a| a.bulk_action? }
164
+ .select { |k, a| a.bulk_action? && a.condition_met?(view_context) }
165
165
  .values
166
166
  end
167
167
 
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.55.0"
2
+ VERSION = "0.56.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.55.0",
3
+ "version": "0.56.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",
data/plutonium.gemspec CHANGED
@@ -17,17 +17,18 @@ Gem::Specification.new do |spec|
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
18
 
19
19
  spec.post_install_message = <<~MSG
20
- ⚠️ Plutonium #{Plutonium::VERSION} — breaking change
20
+ ℹ️ Plutonium — breaking change introduced in 0.49.0
21
21
 
22
- Entity-scoped URL helpers and path params have been renamed from
22
+ Entity-scoped URL helpers and path params were renamed in 0.49.0 from
23
23
  `<entity>_scope_*` to `<entity>_scoped_*`.
24
24
 
25
25
  Examples:
26
26
  organization_scope_widgets_path → organization_scoped_widgets_path
27
27
  params[:organization_scope] → params[:organization_scoped]
28
28
 
29
- If you reference these helpers or params directly (e.g. in tests, custom
30
- redirects, or hand-written links), update them to the new names.
29
+ If you are upgrading from 0.48.0 or earlier and reference these helpers or
30
+ params directly (e.g. in tests, custom redirects, or hand-written links),
31
+ update them to the new names.
31
32
 
32
33
  Apps that only use `resource_url_for` are unaffected.
33
34
  MSG
@@ -178,6 +178,116 @@
178
178
  @apply bg-accent-950/50 text-accent-300 hover:bg-accent-900/60 active:bg-accent-900/80;
179
179
  }
180
180
 
181
+ /* ===================
182
+ BADGES - Status pills
183
+ =================== */
184
+
185
+ .pu-badge {
186
+ @apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap align-middle;
187
+ }
188
+
189
+ .pu-badge-neutral {
190
+ @apply bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] border border-[var(--pu-border-muted)];
191
+ }
192
+
193
+ .pu-badge-primary {
194
+ @apply bg-primary-50 text-primary-700;
195
+ }
196
+ .dark .pu-badge-primary {
197
+ @apply bg-primary-950/50 text-primary-300;
198
+ }
199
+
200
+ .pu-badge-secondary {
201
+ @apply bg-secondary-50 text-secondary-700;
202
+ }
203
+ .dark .pu-badge-secondary {
204
+ @apply bg-secondary-950/50 text-secondary-300;
205
+ }
206
+
207
+ .pu-badge-success {
208
+ @apply bg-success-50 text-success-700;
209
+ }
210
+ .dark .pu-badge-success {
211
+ @apply bg-success-950/50 text-success-300;
212
+ }
213
+
214
+ .pu-badge-danger {
215
+ @apply bg-danger-50 text-danger-700;
216
+ }
217
+ .dark .pu-badge-danger {
218
+ @apply bg-danger-950/50 text-danger-300;
219
+ }
220
+
221
+ .pu-badge-warning {
222
+ @apply bg-warning-50 text-warning-700;
223
+ }
224
+ .dark .pu-badge-warning {
225
+ @apply bg-warning-950/50 text-warning-300;
226
+ }
227
+
228
+ .pu-badge-info {
229
+ @apply bg-info-50 text-info-700;
230
+ }
231
+ .dark .pu-badge-info {
232
+ @apply bg-info-950/50 text-info-300;
233
+ }
234
+
235
+ .pu-badge-accent {
236
+ @apply bg-accent-50 text-accent-700;
237
+ }
238
+ .dark .pu-badge-accent {
239
+ @apply bg-accent-950/50 text-accent-300;
240
+ }
241
+
242
+ /* ===================
243
+ TOGGLE - Switch-styled checkbox
244
+ =================== */
245
+
246
+ .pu-toggle {
247
+ appearance: none;
248
+ -webkit-appearance: none;
249
+ box-sizing: border-box;
250
+ position: relative;
251
+ display: inline-block;
252
+ flex-shrink: 0;
253
+ width: 2.75rem; /* 44px */
254
+ height: 1.5rem; /* 24px */
255
+ padding: 0;
256
+ border: 0;
257
+ border-radius: 9999px;
258
+ background-color: var(--pu-border);
259
+ background-image: none; /* override @tailwindcss/forms checkmark */
260
+ cursor: pointer;
261
+ vertical-align: middle;
262
+ transition: background-color var(--pu-transition-normal, 0.2s);
263
+ }
264
+
265
+ .pu-toggle:checked {
266
+ background-image: none; /* override @tailwindcss/forms checkmark */
267
+ @apply bg-primary-600;
268
+ }
269
+
270
+ .pu-toggle::before {
271
+ content: "";
272
+ position: absolute;
273
+ top: 3px;
274
+ left: 3px;
275
+ width: 18px;
276
+ height: 18px;
277
+ border-radius: 9999px;
278
+ background-color: #fff;
279
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
280
+ transition: transform var(--pu-transition-normal, 0.2s);
281
+ }
282
+
283
+ .pu-toggle:checked::before {
284
+ transform: translateX(20px); /* 44 - 18 - 3 - 3 */
285
+ }
286
+
287
+ .pu-toggle:focus-visible {
288
+ @apply outline-none ring-2 ring-primary-500 ring-offset-2;
289
+ }
290
+
181
291
  /* ===================
182
292
  CARDS - Clean, elevated
183
293
  =================== */
@@ -206,6 +316,22 @@
206
316
  @apply w-full px-3 h-9 text-sm focus:outline-none;
207
317
  }
208
318
 
319
+ /* Native multi-selects (and sized list boxes) must grow — pu-input's fixed
320
+ h-9 collapses them to a single clipped row. */
321
+ select.pu-input[multiple],
322
+ select.pu-input[size]:not([size="1"]) {
323
+ height: auto;
324
+ min-height: 7.5rem;
325
+ @apply py-1.5;
326
+ }
327
+
328
+ select.pu-input[multiple] option {
329
+ border-radius: var(--pu-radius-md);
330
+ margin-block: 4px;
331
+ padding: 0.4rem 0.625rem;
332
+ line-height: 1.4;
333
+ }
334
+
209
335
  .pu-input-toolbar {
210
336
  @apply h-8 text-sm;
211
337
  }
@@ -5,6 +5,27 @@ import { Controller } from "@hotwired/stimulus";
5
5
  // Self-disables when not inside a <dialog>, so it's safe to attach to
6
6
  // every form unconditionally.
7
7
  //
8
+ // Dirtiness is a diff against a baseline captured at the user's *first*
9
+ // real interaction — not at connect. Field widgets (intl-tel-input,
10
+ // flatpickr, slim-select, easymde) mutate the form *after* connect —
11
+ // injecting hidden inputs, reformatting values, replacing the native
12
+ // control — via silent `input.value = …` writes (no event) or synthetic
13
+ // events. Snapshotting at connect counted that settling as edits and
14
+ // prompted on a pristine modal.
15
+ //
16
+ // Instead:
17
+ // • On the first *trusted* pointer/key action inside the form, serialize
18
+ // the form into `baseline`. The user can't interact before the form has
19
+ // rendered, so widgets have already settled — their hidden inputs and
20
+ // reformatted values are part of the baseline, not a phantom diff. The
21
+ // key/pointer event fires *before* the value changes, so the baseline is
22
+ // pre-edit.
23
+ // • On close, the form is dirty if its serialization differs from
24
+ // `baseline`. This is independent of which events a widget dispatches
25
+ // (or whether it dispatches any), catches widget-mediated edits the same
26
+ // as native typing, and — being a diff — treats an edit reverted to its
27
+ // original value as clean. No interaction → no baseline → never dirty.
28
+ //
8
29
  // Esc is intercepted at the document's capture phase: relying on the
9
30
  // dialog's `cancel` event alone proved flaky under rapid/held Esc when
10
31
  // the parent dialog uses `closedby="any"`. The cancel listener stays
@@ -12,24 +33,38 @@ import { Controller } from "@hotwired/stimulus";
12
33
  export default class extends Controller {
13
34
  static targets = ["confirmDialog"];
14
35
 
15
- // Set by controllers, not the user — comparing them would flag
16
- // every form as dirty on connect (return_to) or on submit (pre_submit).
36
+ // Set by controllers, not the user — they're already present (or absent)
37
+ // when the baseline is taken, so they never contribute to the diff; listed
38
+ // for safety against a controller writing them mid-edit.
17
39
  static IGNORED_KEYS = new Set(["authenticity_token", "return_to", "pre_submit"]);
18
40
 
41
+ // Keys that move focus or dismiss the dialog rather than edit it — they
42
+ // must not, on their own, baseline the form.
43
+ static NON_EDITING_KEYS = new Set([
44
+ "Tab", "Escape", "Shift", "Control", "Alt", "Meta",
45
+ ]);
46
+
19
47
  connect() {
20
48
  this.dialog = this.element.closest("dialog");
21
49
  if (!this.dialog) return;
22
50
 
23
- this.snapshot = this.#serialize();
51
+ this.baseline = null;
24
52
  this.forceClose = false;
25
53
  this.submitting = false;
26
54
 
55
+ this.onFirstIntent = this.#onFirstIntent.bind(this);
27
56
  this.onCancel = this.#onCancel.bind(this);
28
57
  this.onSubmit = this.#onSubmit.bind(this);
29
58
  this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
30
59
  this.onConfirmCancel = this.#onConfirmCancel.bind(this);
31
60
  this.onKeydown = this.#onKeydown.bind(this);
32
61
 
62
+ // A trusted pointer/key action inside the form is the user starting to
63
+ // edit — capture the (settled, pre-edit) baseline then. Capture phase so
64
+ // a widget that stops propagation can't hide it from us.
65
+ this.element.addEventListener("pointerdown", this.onFirstIntent, true);
66
+ this.element.addEventListener("keydown", this.onFirstIntent, true);
67
+
33
68
  document.addEventListener("keydown", this.onKeydown, true);
34
69
  // Capture phase so this runs before remote-modal's cancel handler
35
70
  // — that way `defaultPrevented` is visible there if we intervene.
@@ -47,6 +82,8 @@ export default class extends Controller {
47
82
 
48
83
  disconnect() {
49
84
  if (!this.dialog) return;
85
+ this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
86
+ this.element.removeEventListener("keydown", this.onFirstIntent, true);
50
87
  document.removeEventListener("keydown", this.onKeydown, true);
51
88
  this.dialog.removeEventListener("cancel", this.onCancel, true);
52
89
  this.element.removeEventListener("submit", this.onSubmit);
@@ -84,6 +121,17 @@ export default class extends Controller {
84
121
  return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
85
122
  }
86
123
 
124
+ // Capture the baseline the first time the user really touches the form —
125
+ // a trusted pointer or editing keystroke. The form has rendered (so widgets
126
+ // have settled) and the event fires before the value changes, so this is
127
+ // the settled, pre-edit state. Runs once; later interactions are no-ops.
128
+ #onFirstIntent(event) {
129
+ if (this.baseline != null) return;
130
+ if (!event.isTrusted) return;
131
+ if (event.type === "keydown" && this.constructor.NON_EDITING_KEYS.has(event.key)) return;
132
+ this.baseline = this.#serialize();
133
+ }
134
+
87
135
  #serialize() {
88
136
  const data = new FormData(this.element);
89
137
  const enc = encodeURIComponent;
@@ -97,8 +145,11 @@ export default class extends Controller {
97
145
  .join("&");
98
146
  }
99
147
 
148
+ // No interaction → no baseline → never dirty. Otherwise dirty iff the form
149
+ // now serializes differently than it did at first touch (so an edit reverted
150
+ // to its original value reads as clean).
100
151
  #isDirty() {
101
- return this.#serialize() !== this.snapshot;
152
+ return this.baseline != null && this.#serialize() !== this.baseline;
102
153
  }
103
154
 
104
155
  #onSubmit() {