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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-resource/SKILL.md +21 -2
- data/.claude/skills/plutonium-ui/SKILL.md +15 -2
- data/CHANGELOG.md +31 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +94 -26
- data/app/assets/plutonium.js.map +2 -2
- data/app/assets/plutonium.min.js +9 -9
- data/app/assets/plutonium.min.js.map +3 -3
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +18 -2
- data/docs/reference/ui/assets.md +14 -0
- data/docs/reference/ui/displays.md +27 -1
- data/docs/reference/ui/forms.md +2 -1
- data/docs/reference/ui/layouts.md +33 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
- data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +4 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
- data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
- data/lib/plutonium/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/ui/display/base.rb +9 -0
- data/lib/plutonium/ui/display/components/badge.rb +83 -0
- data/lib/plutonium/ui/display/components/boolean.rb +28 -6
- data/lib/plutonium/ui/display/components/currency.rb +50 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +5 -0
- data/lib/plutonium/ui/form/components/toggle.rb +14 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
- data/lib/plutonium/ui/form/interaction.rb +7 -2
- data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
- data/lib/plutonium/ui/form/resource.rb +1 -0
- data/lib/plutonium/ui/form/theme.rb +12 -0
- data/lib/plutonium/ui/grid/card.rb +58 -21
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +126 -0
- data/src/js/controllers/dirty_form_guard_controller.js +55 -4
- data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- metadata +19 -6
data/app/assets/plutonium.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
13082
|
+
this.menu.removeEventListener("mouseenter", this.hoverShowMenuHandler);
|
|
13066
13083
|
this.triggerTarget.removeEventListener("mouseleave", this.hoverHideHandler);
|
|
13067
|
-
this.
|
|
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.
|
|
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.
|
|
13136
|
+
this.menu.addEventListener("mouseenter", this.hoverShowMenuHandler);
|
|
13100
13137
|
this.triggerTarget.addEventListener("mouseleave", this.hoverHideHandler);
|
|
13101
|
-
this.
|
|
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.
|
|
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.
|
|
13139
|
-
this.
|
|
13140
|
-
this.
|
|
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.
|
|
13154
|
-
this.
|
|
13155
|
-
this.
|
|
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 —
|
|
28202
|
-
//
|
|
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.
|
|
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.
|
|
28326
|
+
return this.baseline != null && this.#serialize() !== this.baseline;
|
|
28259
28327
|
}
|
|
28260
28328
|
#onSubmit() {
|
|
28261
28329
|
this.submitting = true;
|