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
@@ -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,10 @@
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;
11510
11525
  }
11511
11526
  };
11512
11527
 
@@ -13024,7 +13039,9 @@
13024
13039
  }
13025
13040
  init() {
13026
13041
  if (this.triggerTarget && this.menuTarget && !this.initialized) {
13027
- 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, {
13028
13045
  strategy: "fixed",
13029
13046
  placement: this.options.placement,
13030
13047
  modifiers: [
@@ -13062,15 +13079,35 @@
13062
13079
  }
13063
13080
  if (this.options.triggerType === "hover") {
13064
13081
  this.triggerTarget.removeEventListener("mouseenter", this.hoverShowTriggerHandler);
13065
- this.menuTarget.removeEventListener("mouseenter", this.hoverShowMenuHandler);
13082
+ this.menu.removeEventListener("mouseenter", this.hoverShowMenuHandler);
13066
13083
  this.triggerTarget.removeEventListener("mouseleave", this.hoverHideHandler);
13067
- this.menuTarget.removeEventListener("mouseleave", this.hoverHideHandler);
13084
+ this.menu.removeEventListener("mouseleave", this.hoverHideHandler);
13068
13085
  }
13069
13086
  this.removeClickOutsideListener();
13087
+ this.restoreMenu();
13088
+ if (this.menu.parentNode === document.body) {
13089
+ this.menu.remove();
13090
+ }
13070
13091
  this.popperInstance.destroy();
13071
13092
  this.initialized = false;
13072
13093
  }
13073
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
+ }
13074
13111
  setupEventListeners() {
13075
13112
  this.clickHandler = this.toggle.bind(this);
13076
13113
  this.hoverShowTriggerHandler = (ev) => {
@@ -13087,7 +13124,7 @@
13087
13124
  };
13088
13125
  this.hoverHideHandler = () => {
13089
13126
  setTimeout(() => {
13090
- if (!this.menuTarget.matches(":hover")) {
13127
+ if (!this.menu.matches(":hover")) {
13091
13128
  this.hide();
13092
13129
  }
13093
13130
  }, this.options.delay);
@@ -13096,9 +13133,9 @@
13096
13133
  this.triggerTarget.addEventListener("click", this.clickHandler);
13097
13134
  } else if (this.options.triggerType === "hover") {
13098
13135
  this.triggerTarget.addEventListener("mouseenter", this.hoverShowTriggerHandler);
13099
- this.menuTarget.addEventListener("mouseenter", this.hoverShowMenuHandler);
13136
+ this.menu.addEventListener("mouseenter", this.hoverShowMenuHandler);
13100
13137
  this.triggerTarget.addEventListener("mouseleave", this.hoverHideHandler);
13101
- this.menuTarget.addEventListener("mouseleave", this.hoverHideHandler);
13138
+ this.menu.addEventListener("mouseleave", this.hoverHideHandler);
13102
13139
  }
13103
13140
  }
13104
13141
  setupClickOutsideListener() {
@@ -13116,7 +13153,7 @@
13116
13153
  });
13117
13154
  }
13118
13155
  const isFloatingUI = clickedEl.closest(".flatpickr-calendar, .ss-main, .ss-content");
13119
- 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) {
13120
13157
  this.hide();
13121
13158
  }
13122
13159
  };
@@ -13135,9 +13172,10 @@
13135
13172
  }
13136
13173
  }
13137
13174
  show() {
13138
- this.menuTarget.classList.remove("hidden");
13139
- this.menuTarget.classList.add("block");
13140
- 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");
13141
13179
  this.popperInstance.setOptions((options2) => ({
13142
13180
  ...options2,
13143
13181
  modifiers: [
@@ -13150,9 +13188,9 @@
13150
13188
  this.visible = true;
13151
13189
  }
13152
13190
  hide() {
13153
- this.menuTarget.classList.remove("block");
13154
- this.menuTarget.classList.add("hidden");
13155
- 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");
13156
13194
  this.popperInstance.setOptions((options2) => ({
13157
13195
  ...options2,
13158
13196
  modifiers: [
@@ -13161,6 +13199,7 @@
13161
13199
  ]
13162
13200
  }));
13163
13201
  this.removeClickOutsideListener();
13202
+ this.restoreMenu();
13164
13203
  this.visible = false;
13165
13204
  }
13166
13205
  };
@@ -28198,20 +28237,34 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28198
28237
  // src/js/controllers/dirty_form_guard_controller.js
28199
28238
  var dirty_form_guard_controller_default = class extends Controller {
28200
28239
  static targets = ["confirmDialog"];
28201
- // Set by controllers, not the user — comparing them would flag
28202
- // 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.
28203
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
+ ]);
28204
28254
  connect() {
28205
28255
  this.dialog = this.element.closest("dialog");
28206
28256
  if (!this.dialog) return;
28207
- this.snapshot = this.#serialize();
28257
+ this.baseline = null;
28208
28258
  this.forceClose = false;
28209
28259
  this.submitting = false;
28260
+ this.onFirstIntent = this.#onFirstIntent.bind(this);
28210
28261
  this.onCancel = this.#onCancel.bind(this);
28211
28262
  this.onSubmit = this.#onSubmit.bind(this);
28212
28263
  this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
28213
28264
  this.onConfirmCancel = this.#onConfirmCancel.bind(this);
28214
28265
  this.onKeydown = this.#onKeydown.bind(this);
28266
+ this.element.addEventListener("pointerdown", this.onFirstIntent, true);
28267
+ this.element.addEventListener("keydown", this.onFirstIntent, true);
28215
28268
  document.addEventListener("keydown", this.onKeydown, true);
28216
28269
  this.dialog.addEventListener("cancel", this.onCancel, true);
28217
28270
  this.element.addEventListener("submit", this.onSubmit);
@@ -28224,6 +28277,8 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28224
28277
  }
28225
28278
  disconnect() {
28226
28279
  if (!this.dialog) return;
28280
+ this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
28281
+ this.element.removeEventListener("keydown", this.onFirstIntent, true);
28227
28282
  document.removeEventListener("keydown", this.onKeydown, true);
28228
28283
  this.dialog.removeEventListener("cancel", this.onCancel, true);
28229
28284
  this.element.removeEventListener("submit", this.onSubmit);
@@ -28246,6 +28301,16 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28246
28301
  if (!this.dialog) return [];
28247
28302
  return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
28248
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
+ }
28249
28314
  #serialize() {
28250
28315
  const data = new FormData(this.element);
28251
28316
  const enc = encodeURIComponent;
@@ -28254,8 +28319,11 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28254
28319
  return `${enc(key)}=${enc(v4)}`;
28255
28320
  }).sort().join("&");
28256
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).
28257
28325
  #isDirty() {
28258
- return this.#serialize() !== this.snapshot;
28326
+ return this.baseline != null && this.#serialize() !== this.baseline;
28259
28327
  }
28260
28328
  #onSubmit() {
28261
28329
  this.submitting = true;