plutonium 0.54.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +76 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +17 -3
  5. data/CHANGELOG.md +45 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +112 -26
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +31 -31
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/initializers/rabl.rb +16 -0
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/public/templates/lite.rb +10 -0
  16. data/docs/reference/generators/lite.md +65 -0
  17. data/docs/reference/resource/definition.md +128 -2
  18. data/docs/reference/ui/assets.md +14 -0
  19. data/docs/reference/ui/displays.md +27 -1
  20. data/docs/reference/ui/forms.md +2 -1
  21. data/docs/reference/ui/layouts.md +33 -0
  22. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  23. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  24. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  25. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  26. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  27. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  28. data/gemfiles/rails_7.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  31. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  32. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  33. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  34. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  37. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  38. data/lib/plutonium/definition/base.rb +1 -0
  39. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  40. data/lib/plutonium/interaction/README.md +24 -78
  41. data/lib/plutonium/interaction/base.rb +10 -2
  42. data/lib/plutonium/models/has_cents.rb +10 -0
  43. data/lib/plutonium/resource/controller.rb +6 -1
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
  45. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  46. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  47. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  48. data/lib/plutonium/ui/display/base.rb +9 -0
  49. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  50. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  51. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  52. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  53. data/lib/plutonium/ui/display/theme.rb +5 -0
  54. data/lib/plutonium/ui/form/base.rb +5 -0
  55. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  56. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
  57. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  58. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
  59. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  60. data/lib/plutonium/ui/form/interaction.rb +7 -2
  61. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  62. data/lib/plutonium/ui/form/resource.rb +5 -1
  63. data/lib/plutonium/ui/form/theme.rb +12 -0
  64. data/lib/plutonium/ui/grid/card.rb +58 -21
  65. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  66. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  67. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  68. data/lib/plutonium/version.rb +1 -1
  69. data/package.json +1 -1
  70. data/plutonium.gemspec +5 -4
  71. data/src/css/components.css +136 -5
  72. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  73. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  74. data/src/js/controllers/register_controllers.js +2 -0
  75. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  76. data/src/js/controllers/structured_input_row_controller.js +26 -0
  77. metadata +30 -8
  78. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  79. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -11479,8 +11479,7 @@
11479
11479
  e4.preventDefault();
11480
11480
  const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, (/* @__PURE__ */ new Date()).getTime().toString());
11481
11481
  this.targetTarget.insertAdjacentHTML("beforebegin", content);
11482
- const event = new CustomEvent("nested-resource-form-fields:add", { bubbles: true });
11483
- this.element.dispatchEvent(event);
11482
+ this.dispatch("add");
11484
11483
  this.updateState();
11485
11484
  }
11486
11485
  remove(e4) {
@@ -11489,15 +11488,29 @@
11489
11488
  if (wrapper.dataset.newRecord !== void 0) {
11490
11489
  wrapper.remove();
11491
11490
  } else {
11492
- wrapper.style.display = "none";
11493
- wrapper.classList.remove(...wrapper.classList);
11494
- const input = wrapper.querySelector("input[name*='_destroy']");
11495
- input.value = "1";
11491
+ this.toggleRemoved(wrapper, true);
11496
11492
  }
11497
- const event = new CustomEvent("nested-resource-form-fields:remove", { bubbles: true });
11498
- this.element.dispatchEvent(event);
11493
+ this.dispatch("remove");
11499
11494
  this.updateState();
11500
11495
  }
11496
+ restore(e4) {
11497
+ e4.preventDefault();
11498
+ const wrapper = e4.target.closest(this.wrapperSelectorValue);
11499
+ this.toggleRemoved(wrapper, false);
11500
+ this.dispatch("restore");
11501
+ this.updateState();
11502
+ }
11503
+ // Collapse a persisted row to its "Removed" bar (or expand it back), keeping
11504
+ // the `_destroy` flag and the removed-state marker in sync.
11505
+ toggleRemoved(wrapper, removed) {
11506
+ wrapper.toggleAttribute("data-removed", removed);
11507
+ const content = wrapper.querySelector(":scope > [data-nested-content]");
11508
+ const removedBar = wrapper.querySelector(":scope > [data-nested-removed]");
11509
+ if (content) content.hidden = removed;
11510
+ if (removedBar) removedBar.hidden = !removed;
11511
+ const destroyInput = wrapper.querySelector("input[name*='_destroy']");
11512
+ if (destroyInput) destroyInput.value = removed ? "1" : "0";
11513
+ }
11501
11514
  updateState() {
11502
11515
  if (!this.hasAddButtonTarget || this.limitValue == 0) return;
11503
11516
  if (this.childCount >= this.limitValue)
@@ -11505,8 +11518,27 @@
11505
11518
  else
11506
11519
  this.addButtonTarget.style.display = "initial";
11507
11520
  }
11521
+ // Removed rows keep their wrapper (so they can be restored) but are excluded
11522
+ // from the count so the limit reflects rows that will actually be saved.
11508
11523
  get childCount() {
11509
- return this.element.querySelectorAll(this.wrapperSelectorValue).length;
11524
+ return this.element.querySelectorAll(`${this.wrapperSelectorValue}:not([data-removed])`).length;
11525
+ }
11526
+ };
11527
+
11528
+ // src/js/controllers/structured_input_row_controller.js
11529
+ var structured_input_row_controller_default = class extends Controller {
11530
+ static targets = ["content", "removed"];
11531
+ remove(e4) {
11532
+ e4.preventDefault();
11533
+ this.contentTarget.disabled = true;
11534
+ this.contentTarget.hidden = true;
11535
+ this.removedTarget.hidden = false;
11536
+ }
11537
+ restore(e4) {
11538
+ e4.preventDefault();
11539
+ this.contentTarget.disabled = false;
11540
+ this.contentTarget.hidden = false;
11541
+ this.removedTarget.hidden = true;
11510
11542
  }
11511
11543
  };
11512
11544
 
@@ -13007,7 +13039,9 @@
13007
13039
  }
13008
13040
  init() {
13009
13041
  if (this.triggerTarget && this.menuTarget && !this.initialized) {
13010
- this.popperInstance = createPopper(this.triggerTarget, this.menuTarget, {
13042
+ this.menu = this.menuTarget;
13043
+ this.menuHome = { parent: this.menu.parentNode, next: this.menu.nextSibling };
13044
+ this.popperInstance = createPopper(this.triggerTarget, this.menu, {
13011
13045
  strategy: "fixed",
13012
13046
  placement: this.options.placement,
13013
13047
  modifiers: [
@@ -13045,15 +13079,35 @@
13045
13079
  }
13046
13080
  if (this.options.triggerType === "hover") {
13047
13081
  this.triggerTarget.removeEventListener("mouseenter", this.hoverShowTriggerHandler);
13048
- this.menuTarget.removeEventListener("mouseenter", this.hoverShowMenuHandler);
13082
+ this.menu.removeEventListener("mouseenter", this.hoverShowMenuHandler);
13049
13083
  this.triggerTarget.removeEventListener("mouseleave", this.hoverHideHandler);
13050
- this.menuTarget.removeEventListener("mouseleave", this.hoverHideHandler);
13084
+ this.menu.removeEventListener("mouseleave", this.hoverHideHandler);
13051
13085
  }
13052
13086
  this.removeClickOutsideListener();
13087
+ this.restoreMenu();
13088
+ if (this.menu.parentNode === document.body) {
13089
+ this.menu.remove();
13090
+ }
13053
13091
  this.popperInstance.destroy();
13054
13092
  this.initialized = false;
13055
13093
  }
13056
13094
  }
13095
+ // Move the menu to <body> so no ancestor's overflow + containing-block
13096
+ // (transform/filter/will-change) can clip it. popper's 'fixed' strategy
13097
+ // positions relative to the viewport, but painting is still clipped by a
13098
+ // transformed, overflow-hidden ancestor (e.g. grid cards) — teleporting
13099
+ // sidesteps that entirely.
13100
+ teleportMenu() {
13101
+ if (this.menu.parentNode !== document.body) {
13102
+ document.body.appendChild(this.menu);
13103
+ }
13104
+ }
13105
+ restoreMenu() {
13106
+ const home = this.menuHome;
13107
+ if (home && home.parent && home.parent.isConnected && this.menu.parentNode !== home.parent) {
13108
+ home.parent.insertBefore(this.menu, home.next);
13109
+ }
13110
+ }
13057
13111
  setupEventListeners() {
13058
13112
  this.clickHandler = this.toggle.bind(this);
13059
13113
  this.hoverShowTriggerHandler = (ev) => {
@@ -13070,7 +13124,7 @@
13070
13124
  };
13071
13125
  this.hoverHideHandler = () => {
13072
13126
  setTimeout(() => {
13073
- if (!this.menuTarget.matches(":hover")) {
13127
+ if (!this.menu.matches(":hover")) {
13074
13128
  this.hide();
13075
13129
  }
13076
13130
  }, this.options.delay);
@@ -13079,9 +13133,9 @@
13079
13133
  this.triggerTarget.addEventListener("click", this.clickHandler);
13080
13134
  } else if (this.options.triggerType === "hover") {
13081
13135
  this.triggerTarget.addEventListener("mouseenter", this.hoverShowTriggerHandler);
13082
- this.menuTarget.addEventListener("mouseenter", this.hoverShowMenuHandler);
13136
+ this.menu.addEventListener("mouseenter", this.hoverShowMenuHandler);
13083
13137
  this.triggerTarget.addEventListener("mouseleave", this.hoverHideHandler);
13084
- this.menuTarget.addEventListener("mouseleave", this.hoverHideHandler);
13138
+ this.menu.addEventListener("mouseleave", this.hoverHideHandler);
13085
13139
  }
13086
13140
  }
13087
13141
  setupClickOutsideListener() {
@@ -13099,7 +13153,7 @@
13099
13153
  });
13100
13154
  }
13101
13155
  const isFloatingUI = clickedEl.closest(".flatpickr-calendar, .ss-main, .ss-content");
13102
- if (clickedEl !== this.menuTarget && !this.menuTarget.contains(clickedEl) && !this.triggerTarget.contains(clickedEl) && !isIgnored && !isFloatingUI && this.visible) {
13156
+ if (clickedEl !== this.menu && !this.menu.contains(clickedEl) && !this.triggerTarget.contains(clickedEl) && !isIgnored && !isFloatingUI && this.visible) {
13103
13157
  this.hide();
13104
13158
  }
13105
13159
  };
@@ -13118,9 +13172,10 @@
13118
13172
  }
13119
13173
  }
13120
13174
  show() {
13121
- this.menuTarget.classList.remove("hidden");
13122
- this.menuTarget.classList.add("block");
13123
- this.menuTarget.removeAttribute("aria-hidden");
13175
+ this.teleportMenu();
13176
+ this.menu.classList.remove("hidden");
13177
+ this.menu.classList.add("block");
13178
+ this.menu.removeAttribute("aria-hidden");
13124
13179
  this.popperInstance.setOptions((options2) => ({
13125
13180
  ...options2,
13126
13181
  modifiers: [
@@ -13133,9 +13188,9 @@
13133
13188
  this.visible = true;
13134
13189
  }
13135
13190
  hide() {
13136
- this.menuTarget.classList.remove("block");
13137
- this.menuTarget.classList.add("hidden");
13138
- this.menuTarget.setAttribute("aria-hidden", "true");
13191
+ this.menu.classList.remove("block");
13192
+ this.menu.classList.add("hidden");
13193
+ this.menu.setAttribute("aria-hidden", "true");
13139
13194
  this.popperInstance.setOptions((options2) => ({
13140
13195
  ...options2,
13141
13196
  modifiers: [
@@ -13144,6 +13199,7 @@
13144
13199
  ]
13145
13200
  }));
13146
13201
  this.removeClickOutsideListener();
13202
+ this.restoreMenu();
13147
13203
  this.visible = false;
13148
13204
  }
13149
13205
  };
@@ -28181,20 +28237,34 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28181
28237
  // src/js/controllers/dirty_form_guard_controller.js
28182
28238
  var dirty_form_guard_controller_default = class extends Controller {
28183
28239
  static targets = ["confirmDialog"];
28184
- // Set by controllers, not the user — comparing them would flag
28185
- // every form as dirty on connect (return_to) or on submit (pre_submit).
28240
+ // Set by controllers, not the user — they're already present (or absent)
28241
+ // when the baseline is taken, so they never contribute to the diff; listed
28242
+ // for safety against a controller writing them mid-edit.
28186
28243
  static IGNORED_KEYS = /* @__PURE__ */ new Set(["authenticity_token", "return_to", "pre_submit"]);
28244
+ // Keys that move focus or dismiss the dialog rather than edit it — they
28245
+ // must not, on their own, baseline the form.
28246
+ static NON_EDITING_KEYS = /* @__PURE__ */ new Set([
28247
+ "Tab",
28248
+ "Escape",
28249
+ "Shift",
28250
+ "Control",
28251
+ "Alt",
28252
+ "Meta"
28253
+ ]);
28187
28254
  connect() {
28188
28255
  this.dialog = this.element.closest("dialog");
28189
28256
  if (!this.dialog) return;
28190
- this.snapshot = this.#serialize();
28257
+ this.baseline = null;
28191
28258
  this.forceClose = false;
28192
28259
  this.submitting = false;
28260
+ this.onFirstIntent = this.#onFirstIntent.bind(this);
28193
28261
  this.onCancel = this.#onCancel.bind(this);
28194
28262
  this.onSubmit = this.#onSubmit.bind(this);
28195
28263
  this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
28196
28264
  this.onConfirmCancel = this.#onConfirmCancel.bind(this);
28197
28265
  this.onKeydown = this.#onKeydown.bind(this);
28266
+ this.element.addEventListener("pointerdown", this.onFirstIntent, true);
28267
+ this.element.addEventListener("keydown", this.onFirstIntent, true);
28198
28268
  document.addEventListener("keydown", this.onKeydown, true);
28199
28269
  this.dialog.addEventListener("cancel", this.onCancel, true);
28200
28270
  this.element.addEventListener("submit", this.onSubmit);
@@ -28207,6 +28277,8 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28207
28277
  }
28208
28278
  disconnect() {
28209
28279
  if (!this.dialog) return;
28280
+ this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
28281
+ this.element.removeEventListener("keydown", this.onFirstIntent, true);
28210
28282
  document.removeEventListener("keydown", this.onKeydown, true);
28211
28283
  this.dialog.removeEventListener("cancel", this.onCancel, true);
28212
28284
  this.element.removeEventListener("submit", this.onSubmit);
@@ -28229,6 +28301,16 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28229
28301
  if (!this.dialog) return [];
28230
28302
  return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
28231
28303
  }
28304
+ // Capture the baseline the first time the user really touches the form —
28305
+ // a trusted pointer or editing keystroke. The form has rendered (so widgets
28306
+ // have settled) and the event fires before the value changes, so this is
28307
+ // the settled, pre-edit state. Runs once; later interactions are no-ops.
28308
+ #onFirstIntent(event) {
28309
+ if (this.baseline != null) return;
28310
+ if (!event.isTrusted) return;
28311
+ if (event.type === "keydown" && this.constructor.NON_EDITING_KEYS.has(event.key)) return;
28312
+ this.baseline = this.#serialize();
28313
+ }
28232
28314
  #serialize() {
28233
28315
  const data = new FormData(this.element);
28234
28316
  const enc = encodeURIComponent;
@@ -28237,8 +28319,11 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28237
28319
  return `${enc(key)}=${enc(v4)}`;
28238
28320
  }).sort().join("&");
28239
28321
  }
28322
+ // No interaction → no baseline → never dirty. Otherwise dirty iff the form
28323
+ // now serializes differently than it did at first touch (so an edit reverted
28324
+ // to its original value reads as clean).
28240
28325
  #isDirty() {
28241
- return this.#serialize() !== this.snapshot;
28326
+ return this.baseline != null && this.#serialize() !== this.baseline;
28242
28327
  }
28243
28328
  #onSubmit() {
28244
28329
  this.submitting = true;
@@ -28318,6 +28403,7 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28318
28403
  application2.register("sidebar", sidebar_controller_default);
28319
28404
  application2.register("resource-header", resource_header_controller_default);
28320
28405
  application2.register("nested-resource-form-fields", nested_resource_form_fields_controller_default);
28406
+ application2.register("structured-input-row", structured_input_row_controller_default);
28321
28407
  application2.register("form", form_controller_default);
28322
28408
  application2.register("resource-drop-down", resource_drop_down_controller_default);
28323
28409
  application2.register("resource-collapse", resource_collapse_controller_default);