plutonium 0.55.0 → 0.56.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +21 -2
  3. data/.claude/skills/plutonium-ui/SKILL.md +15 -2
  4. data/CHANGELOG.md +31 -0
  5. data/app/assets/plutonium.css +1 -1
  6. data/app/assets/plutonium.js +94 -26
  7. data/app/assets/plutonium.js.map +2 -2
  8. data/app/assets/plutonium.min.js +9 -9
  9. data/app/assets/plutonium.min.js.map +3 -3
  10. data/config/initializers/rabl.rb +16 -0
  11. data/docs/.vitepress/config.ts +1 -0
  12. data/docs/public/templates/lite.rb +10 -0
  13. data/docs/reference/generators/lite.md +65 -0
  14. data/docs/reference/resource/definition.md +18 -2
  15. data/docs/reference/ui/assets.md +14 -0
  16. data/docs/reference/ui/displays.md +27 -1
  17. data/docs/reference/ui/forms.md +2 -1
  18. data/docs/reference/ui/layouts.md +33 -0
  19. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  20. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  21. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  22. data/gemfiles/rails_7.gemfile.lock +1 -1
  23. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  24. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  25. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  26. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  27. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  28. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  29. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  30. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  31. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  32. data/lib/plutonium/models/has_cents.rb +10 -0
  33. data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
  34. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  35. data/lib/plutonium/ui/display/base.rb +9 -0
  36. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  37. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  38. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  39. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  40. data/lib/plutonium/ui/display/theme.rb +5 -0
  41. data/lib/plutonium/ui/form/base.rb +5 -0
  42. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  43. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
  44. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  45. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
  46. data/lib/plutonium/ui/form/interaction.rb +7 -2
  47. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  48. data/lib/plutonium/ui/form/resource.rb +1 -0
  49. data/lib/plutonium/ui/form/theme.rb +12 -0
  50. data/lib/plutonium/ui/grid/card.rb +58 -21
  51. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  52. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  53. data/lib/plutonium/version.rb +1 -1
  54. data/package.json +1 -1
  55. data/plutonium.gemspec +5 -4
  56. data/src/css/components.css +126 -0
  57. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  58. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  59. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  60. metadata +19 -6
@@ -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
  # ---------------------------------------------------------------
@@ -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
@@ -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",
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.55.0"
2
+ VERSION = "0.56.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.55.0",
3
+ "version": "0.56.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
@@ -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() {
@@ -1,7 +1,12 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="nested-resource-form-fields"
4
- // Copied from https://github.com/stimulus-components/stimulus-rails-nested-form/blob/master/src/index.ts
4
+ // Adapted from https://github.com/stimulus-components/stimulus-rails-nested-form
5
+ //
6
+ // Persisted rows soft-delete: the row collapses to a "Removed — Restore" bar,
7
+ // its `_destroy` flag flips to "1", and it drops out of the row count so the
8
+ // add button can come back. Restoring reverses all three. Unpersisted rows are
9
+ // removed from the DOM outright — there is nothing to restore server-side.
5
10
  export default class extends Controller {
6
11
  static targets = ["target", "template", "addButton"]
7
12
 
@@ -23,9 +28,7 @@ export default class extends Controller {
23
28
  const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime().toString())
24
29
  this.targetTarget.insertAdjacentHTML("beforebegin", content)
25
30
 
26
- const event = new CustomEvent("nested-resource-form-fields:add", { bubbles: true })
27
- this.element.dispatchEvent(event)
28
-
31
+ this.dispatch("add")
29
32
  this.updateState()
30
33
  }
31
34
 
@@ -36,19 +39,37 @@ export default class extends Controller {
36
39
  if (wrapper.dataset.newRecord !== undefined) {
37
40
  wrapper.remove()
38
41
  } else {
39
- wrapper.style.display = "none"
40
- wrapper.classList.remove(...wrapper.classList)
41
-
42
- const input = wrapper.querySelector("input[name*='_destroy']")
43
- input.value = "1"
42
+ this.toggleRemoved(wrapper, true)
44
43
  }
45
44
 
46
- const event = new CustomEvent("nested-resource-form-fields:remove", { bubbles: true })
47
- this.element.dispatchEvent(event)
45
+ this.dispatch("remove")
46
+ this.updateState()
47
+ }
48
+
49
+ restore(e) {
50
+ e.preventDefault()
51
+
52
+ const wrapper = e.target.closest(this.wrapperSelectorValue)
53
+ this.toggleRemoved(wrapper, false)
48
54
 
55
+ this.dispatch("restore")
49
56
  this.updateState()
50
57
  }
51
58
 
59
+ // Collapse a persisted row to its "Removed" bar (or expand it back), keeping
60
+ // the `_destroy` flag and the removed-state marker in sync.
61
+ toggleRemoved(wrapper, removed) {
62
+ wrapper.toggleAttribute("data-removed", removed)
63
+
64
+ const content = wrapper.querySelector(":scope > [data-nested-content]")
65
+ const removedBar = wrapper.querySelector(":scope > [data-nested-removed]")
66
+ if (content) content.hidden = removed
67
+ if (removedBar) removedBar.hidden = !removed
68
+
69
+ const destroyInput = wrapper.querySelector("input[name*='_destroy']")
70
+ if (destroyInput) destroyInput.value = removed ? "1" : "0"
71
+ }
72
+
52
73
  updateState() {
53
74
  if (!this.hasAddButtonTarget || this.limitValue == 0) return
54
75
 
@@ -58,7 +79,9 @@ export default class extends Controller {
58
79
  this.addButtonTarget.style.display = "initial"
59
80
  }
60
81
 
82
+ // Removed rows keep their wrapper (so they can be restored) but are excluded
83
+ // from the count so the limit reflects rows that will actually be saved.
61
84
  get childCount() {
62
- return this.element.querySelectorAll(this.wrapperSelectorValue).length
85
+ return this.element.querySelectorAll(`${this.wrapperSelectorValue}:not([data-removed])`).length
63
86
  }
64
87
  }