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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
- data/.claude/skills/plutonium-resource/SKILL.md +76 -2
- data/.claude/skills/plutonium-ui/SKILL.md +17 -3
- data/CHANGELOG.md +45 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +112 -26
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +128 -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-02-structured-inputs.md +1061 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -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-01-structured-inputs-design.md +191 -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/definition/base.rb +1 -0
- data/lib/plutonium/definition/structured_inputs.rb +67 -0
- data/lib/plutonium/interaction/README.md +24 -78
- data/lib/plutonium/interaction/base.rb +10 -2
- data/lib/plutonium/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controller.rb +6 -1
- data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
- data/lib/plutonium/structured_inputs/params_concern.rb +36 -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 +17 -28
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
- data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
- 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 +5 -1
- 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/modal/slideover.rb +9 -3
- 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 +136 -5
- 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/register_controllers.js +2 -0
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- data/src/js/controllers/structured_input_row_controller.js +26 -0
- metadata +30 -8
- data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
- data/lib/plutonium/interaction/nested_attributes.rb +0 -93
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,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.
|
|
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.
|
|
13082
|
+
this.menu.removeEventListener("mouseenter", this.hoverShowMenuHandler);
|
|
13049
13083
|
this.triggerTarget.removeEventListener("mouseleave", this.hoverHideHandler);
|
|
13050
|
-
this.
|
|
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.
|
|
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.
|
|
13136
|
+
this.menu.addEventListener("mouseenter", this.hoverShowMenuHandler);
|
|
13083
13137
|
this.triggerTarget.addEventListener("mouseleave", this.hoverHideHandler);
|
|
13084
|
-
this.
|
|
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.
|
|
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.
|
|
13122
|
-
this.
|
|
13123
|
-
this.
|
|
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.
|
|
13137
|
-
this.
|
|
13138
|
-
this.
|
|
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 —
|
|
28185
|
-
//
|
|
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.
|
|
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.
|
|
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);
|