plutonium 0.61.0 → 0.62.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
  3. data/CHANGELOG.md +27 -0
  4. data/app/assets/plutonium.css +1 -1
  5. data/app/assets/plutonium.js +315 -38
  6. data/app/assets/plutonium.js.map +4 -4
  7. data/app/assets/plutonium.min.js +31 -31
  8. data/app/assets/plutonium.min.js.map +4 -4
  9. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  10. data/app/views/resource/kanban_move_form.html.erb +1 -0
  11. data/config/brakeman.ignore +2 -2
  12. data/docs/.vitepress/config.ts +21 -1
  13. data/docs/.vitepress/sync-skills.mjs +45 -0
  14. data/docs/ai.md +99 -0
  15. data/docs/guides/kanban.md +128 -18
  16. data/docs/reference/kanban/authorization.md +25 -5
  17. data/docs/reference/kanban/dsl.md +49 -8
  18. data/docs/reference/kanban/index.md +3 -3
  19. data/docs/reference/kanban/positioning.md +1 -1
  20. data/docs/reference/resource/definition.md +10 -1
  21. data/docs/reference/resource/model.md +26 -0
  22. data/docs/reference/ui/forms.md +41 -0
  23. data/docs/reference/wizard/dsl.md +5 -0
  24. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  25. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  26. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  27. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  28. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  29. data/lib/plutonium/action/base.rb +8 -0
  30. data/lib/plutonium/configuration.rb +12 -0
  31. data/lib/plutonium/definition/index_views.rb +16 -0
  32. data/lib/plutonium/kanban/column.rb +80 -27
  33. data/lib/plutonium/models/has_cents.rb +30 -2
  34. data/lib/plutonium/resource/controller.rb +22 -1
  35. data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
  36. data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
  37. data/lib/plutonium/resource/policy.rb +6 -0
  38. data/lib/plutonium/routing/mapper_extensions.rb +1 -0
  39. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  40. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  41. data/lib/plutonium/ui/form/base.rb +6 -0
  42. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  43. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  44. data/lib/plutonium/ui/form/components/uppy.rb +20 -2
  45. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  46. data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
  47. data/lib/plutonium/ui/form/resource.rb +12 -0
  48. data/lib/plutonium/ui/form/theme.rb +7 -0
  49. data/lib/plutonium/ui/grid/card.rb +40 -13
  50. data/lib/plutonium/ui/kanban/column.rb +111 -24
  51. data/lib/plutonium/ui/kanban/resource.rb +118 -11
  52. data/lib/plutonium/ui/layout/base.rb +1 -1
  53. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  54. data/lib/plutonium/ui/page/index.rb +1 -1
  55. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  56. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  57. data/lib/plutonium/ui/page/show.rb +7 -2
  58. data/lib/plutonium/ui/table/resource.rb +1 -1
  59. data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
  60. data/lib/plutonium/version.rb +1 -1
  61. data/package.json +5 -3
  62. data/src/css/components.css +5 -0
  63. data/src/js/controllers/currency_input_controller.js +39 -0
  64. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  65. data/src/js/controllers/kanban_controller.js +442 -55
  66. data/src/js/controllers/register_controllers.js +2 -0
  67. data/yarn.lock +674 -4
  68. metadata +14 -2
@@ -17340,6 +17340,7 @@ ${text2}</tr>
17340
17340
  // src/js/controllers/intl_tel_input_controller.js
17341
17341
  var intl_tel_input_controller_default = class extends Controller {
17342
17342
  static targets = ["input"];
17343
+ static values = { options: Object };
17343
17344
  connect() {
17344
17345
  }
17345
17346
  disconnect() {
@@ -17376,7 +17377,8 @@ ${text2}</tr>
17376
17377
  return {
17377
17378
  strictMode: true,
17378
17379
  hiddenInput: () => ({ phone: this.inputTarget.attributes.name.value }),
17379
- loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js"
17380
+ loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js",
17381
+ ...this.optionsValue
17380
17382
  };
17381
17383
  }
17382
17384
  };
@@ -28913,7 +28915,7 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28913
28915
 
28914
28916
  // src/js/controllers/kanban_controller.js
28915
28917
  var kanban_controller_default = class extends Controller {
28916
- static values = { moveUrlTemplate: String };
28918
+ static values = { moveUrlTemplate: String, collapseCookie: String, collapsePath: String };
28917
28919
  static targets = ["column"];
28918
28920
  connect() {
28919
28921
  this.draggedCard = null;
@@ -28927,7 +28929,30 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28927
28929
  this.element.addEventListener("dragleave", this.onDragLeave);
28928
28930
  this.element.addEventListener("drop", this.onDrop);
28929
28931
  this.element.addEventListener("dragend", this.onDragEnd);
28930
- this.#applyPersistedCollapseStates();
28932
+ this.scrollTarget = this.#readStoredScroll();
28933
+ this.captureScroll = this.#captureScroll.bind(this);
28934
+ this.onBoardScroll = () => {
28935
+ if (this.restoringScroll) return;
28936
+ clearTimeout(this.scrollSaveTimer);
28937
+ this.scrollSaveTimer = setTimeout(this.captureScroll, 120);
28938
+ };
28939
+ this.element.addEventListener("scroll", this.onBoardScroll, { passive: true });
28940
+ this.onUserScrollIntent = () => this.#endScrollRestore();
28941
+ this.element.addEventListener("wheel", this.onUserScrollIntent, { passive: true });
28942
+ this.element.addEventListener("touchmove", this.onUserScrollIntent, { passive: true });
28943
+ this.onPageHide = this.captureScroll;
28944
+ window.addEventListener("pagehide", this.onPageHide);
28945
+ this.onTurboLoad = this.#syncColumnsToUrl.bind(this);
28946
+ this.onBeforeFrameRender = this.#onBeforeFrameRender.bind(this);
28947
+ this.onFrameRender = this.#onFrameRender.bind(this);
28948
+ this.onBeforeStreamRender = this.#onBeforeStreamRender.bind(this);
28949
+ document.addEventListener("turbo:load", this.onTurboLoad);
28950
+ document.addEventListener("turbo:before-frame-render", this.onBeforeFrameRender);
28951
+ document.addEventListener("turbo:frame-render", this.onFrameRender);
28952
+ document.addEventListener("turbo:before-stream-render", this.onBeforeStreamRender);
28953
+ this.#syncColumnsToUrl();
28954
+ this.#restoreScrollLeft();
28955
+ this.#reloadAfterWrite();
28931
28956
  }
28932
28957
  disconnect() {
28933
28958
  this.element.removeEventListener("dragstart", this.onDragStart);
@@ -28935,6 +28960,189 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28935
28960
  this.element.removeEventListener("dragleave", this.onDragLeave);
28936
28961
  this.element.removeEventListener("drop", this.onDrop);
28937
28962
  this.element.removeEventListener("dragend", this.onDragEnd);
28963
+ this.#captureScroll();
28964
+ this.element.removeEventListener("scroll", this.onBoardScroll);
28965
+ this.element.removeEventListener("wheel", this.onUserScrollIntent);
28966
+ this.element.removeEventListener("touchmove", this.onUserScrollIntent);
28967
+ window.removeEventListener("pagehide", this.onPageHide);
28968
+ clearTimeout(this.restoreScrollTimer);
28969
+ clearTimeout(this.scrollSaveTimer);
28970
+ document.removeEventListener("turbo:load", this.onTurboLoad);
28971
+ document.removeEventListener("turbo:before-frame-render", this.onBeforeFrameRender);
28972
+ document.removeEventListener("turbo:frame-render", this.onFrameRender);
28973
+ document.removeEventListener("turbo:before-stream-render", this.onBeforeStreamRender);
28974
+ }
28975
+ // ─── Frozen-board URL sync ────────────────────────────────────────────────────
28976
+ // Reconcile every column frame's src with the current URL's board params
28977
+ // (q / scope / sort). The board is frozen (data-turbo-permanent), so its
28978
+ // frames keep whatever src they were last loaded with; this is what reflects a
28979
+ // new search / filter / scope into the columns.
28980
+ //
28981
+ // STATELESS by design: we compare each frame's actual src against the src the
28982
+ // current URL implies and reload only the ones that differ. We deliberately do
28983
+ // NOT track "last synced URL" — Turbo reconnects this controller on every nav
28984
+ // (the permanent board is transplanted), so any per-connect state would be
28985
+ // reset to the new URL before we could diff against it, and the sync would
28986
+ // never fire. Comparing frame-src-vs-URL has no such blind spot and is
28987
+ // self-limiting: once a frame matches the URL, it won't reload again.
28988
+ // A create/update/destroy that returns to the board redirects to a URL the
28989
+ // server tagged with kanban_reload=1 (see KanbanActions#kanban_reload_url).
28990
+ // The board is data-turbo-permanent, so its already-loaded column frames are
28991
+ // kept as-is on arrival — stale (missing a new card, still showing a deleted
28992
+ // one). Force every column to re-fetch, then strip the marker so a later
28993
+ // reload / back-nav doesn't re-trigger it. The restore window opened by
28994
+ // #restoreScrollLeft keeps the scroll pinned as the fresh frames render.
28995
+ #reloadAfterWrite() {
28996
+ const url = new URL(window.location.href);
28997
+ if (!url.searchParams.has("kanban_reload")) return;
28998
+ url.searchParams.delete("kanban_reload");
28999
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
29000
+ this.#columnFrames().forEach((frame) => frame.reload());
29001
+ }
29002
+ #syncColumnsToUrl() {
29003
+ this.#columnFrames().forEach((frame) => {
29004
+ const desired = this.#columnFrameSrc(frame.dataset.kanbanColFrame);
29005
+ const current = frame.getAttribute("src");
29006
+ if (current && this.#canonicalUrl(current) === this.#canonicalUrl(desired)) return;
29007
+ frame.src = desired;
29008
+ });
29009
+ }
29010
+ // The src a column frame should carry for the current URL: the page's query
29011
+ // params with view=kanban + column=<key> forced on. Mirrors the server's
29012
+ // Kanban::Resource#column_frame_src so a freshly-rendered board's frames
29013
+ // already match (no spurious reload).
29014
+ #columnFrameSrc(key) {
29015
+ const params = new URLSearchParams(window.location.search);
29016
+ params.set("view", "kanban");
29017
+ params.set("column", key);
29018
+ return `${window.location.pathname}?${params.toString()}`;
29019
+ }
29020
+ // Order-independent identity for a frame src: path + alphabetically sorted
29021
+ // query params. The server serialises params sorted (Hash#to_query) while
29022
+ // URLSearchParams preserves insertion order, so raw string comparison would
29023
+ // report a false difference and reload every frame on every nav.
29024
+ #canonicalUrl(src) {
29025
+ const url = new URL(src, window.location.origin);
29026
+ url.searchParams.sort();
29027
+ return `${url.pathname}?${url.searchParams.toString()}`;
29028
+ }
29029
+ // Turbo fires this before a frame renders fetched content. For our column
29030
+ // frames we swap in a morph render so the reload diffs the card list rather
29031
+ // than replacing it (which would blank the column). Uses Turbo's own frame
29032
+ // morph so before/after hooks (turbo:before-frame-morph) still fire.
29033
+ #onBeforeFrameRender(event) {
29034
+ if (!this.#isColumnFrame(event.target)) return;
29035
+ event.detail.render = (currentElement, newElement) => morphTurboFrameElements(currentElement, newElement);
29036
+ }
29037
+ #onFrameRender(event) {
29038
+ if (!this.#isColumnFrame(event.target)) return;
29039
+ if (this.restoringScroll) {
29040
+ this.#pinScroll();
29041
+ this.#bumpRestoreTimer();
29042
+ }
29043
+ }
29044
+ // Apply the saved horizontal position: to the live max when the user was at
29045
+ // the end (so it tracks late width changes), otherwise to the exact offset.
29046
+ // No-op when nothing meaningful was saved so it can't yank a fresh board to 0.
29047
+ #pinScroll() {
29048
+ const t4 = this.scrollTarget;
29049
+ if (!t4 || !t4.l && !t4.e) return;
29050
+ this.element.scrollLeft = t4.e ? this.element.scrollWidth : t4.l;
29051
+ }
29052
+ // Re-apply the saved horizontal scroll across a window that extends with each
29053
+ // column render, because renders (morph on nav, replace on move, lazy load on
29054
+ // full reload) settle their width late and each settle can clamp scrollLeft
29055
+ // back toward 0. Used by the reattach, turbo-stream, and initial-load paths.
29056
+ #scheduleScrollRestore() {
29057
+ if (!this.scrollTarget || !this.scrollTarget.l && !this.scrollTarget.e) return;
29058
+ this.restoringScroll = true;
29059
+ this.#pinScroll();
29060
+ requestAnimationFrame(() => this.#pinScroll());
29061
+ this.#bumpRestoreTimer();
29062
+ }
29063
+ // (Re)arm the timer that ends the restore window. Reset on every column render
29064
+ // so the window lives ~400ms past the LAST column to settle — enough for slow
29065
+ // lazy frames on a fresh load without pinning forever.
29066
+ #bumpRestoreTimer() {
29067
+ clearTimeout(this.restoreScrollTimer);
29068
+ this.restoreScrollTimer = setTimeout(() => this.#endScrollRestore(), 400);
29069
+ }
29070
+ #endScrollRestore() {
29071
+ if (!this.restoringScroll) return;
29072
+ this.#pinScroll();
29073
+ this.restoringScroll = false;
29074
+ clearTimeout(this.restoreScrollTimer);
29075
+ }
29076
+ // Restore on connect — covers Turbo reattach (search/filter) and a full page
29077
+ // reload (F5), since scrollTarget is seeded from sessionStorage either way.
29078
+ #restoreScrollLeft() {
29079
+ this.#scheduleScrollRestore();
29080
+ }
29081
+ // ── scroll persistence (sessionStorage) ──
29082
+ // Per-tab and auto-cleared on tab close, so it can't accumulate. Keyed by the
29083
+ // board's collection path (tenant + resource scoped) via the move template.
29084
+ #scrollKey() {
29085
+ const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "");
29086
+ return `pu-kanban-scroll:${path}`;
29087
+ }
29088
+ #readStoredScroll() {
29089
+ try {
29090
+ const raw = sessionStorage.getItem(this.#scrollKey());
29091
+ return raw ? JSON.parse(raw) : null;
29092
+ } catch {
29093
+ return null;
29094
+ }
29095
+ }
29096
+ // Read the live position (unless a restore currently owns scrollTarget) and
29097
+ // persist it. Runs on the scroll debounce, on pagehide, and on disconnect —
29098
+ // never on the raw scroll event, so the layout read can't stutter scrolling.
29099
+ #captureScroll() {
29100
+ const el = this.element;
29101
+ if (!this.restoringScroll && el.clientWidth > 0) {
29102
+ const maxScroll = el.scrollWidth - el.clientWidth;
29103
+ this.scrollTarget = {
29104
+ l: el.scrollLeft,
29105
+ e: maxScroll > 0 && el.scrollLeft >= maxScroll - 2
29106
+ };
29107
+ }
29108
+ if (!this.scrollTarget) return;
29109
+ try {
29110
+ sessionStorage.setItem(this.#scrollKey(), JSON.stringify(this.scrollTarget));
29111
+ } catch {
29112
+ }
29113
+ }
29114
+ // A move / realtime update re-renders columns via turbo-stream (replace). The
29115
+ // server renders them in the user's collapse state, so we only need to protect
29116
+ // the horizontal scroll: freeze tracking BEFORE the swap (replacing a column
29117
+ // briefly removes it, narrowing the board so scrollLeft clamps toward 0 — that
29118
+ // clamp would otherwise be saved as the new position, a visible jump at the
29119
+ // far end), then restore once the columns are back.
29120
+ #onBeforeStreamRender(event) {
29121
+ if (!this.#streamTargetsColumn(event.target)) return;
29122
+ const render = event.detail.render;
29123
+ event.detail.render = async (streamElement) => {
29124
+ this.restoringScroll = true;
29125
+ await render(streamElement);
29126
+ this.#scheduleScrollRestore();
29127
+ };
29128
+ }
29129
+ // True when the <turbo-stream> element targets a column frame contained by
29130
+ // this board — via its `target` (frame id) or `targets` (CSS selector).
29131
+ #streamTargetsColumn(streamElement) {
29132
+ if (!streamElement) return false;
29133
+ const target = streamElement.getAttribute("target");
29134
+ if (target) return this.#isColumnFrame(document.getElementById(target));
29135
+ const targets = streamElement.getAttribute("targets");
29136
+ if (targets) {
29137
+ return [...document.querySelectorAll(targets)].some((el) => this.#isColumnFrame(el));
29138
+ }
29139
+ return false;
29140
+ }
29141
+ #columnFrames() {
29142
+ return this.element.querySelectorAll("turbo-frame[data-kanban-col-frame]");
29143
+ }
29144
+ #isColumnFrame(el) {
29145
+ return el?.matches?.("turbo-frame[data-kanban-col-frame]") && this.element.contains(el);
28938
29146
  }
28939
29147
  // ─── Collapse toggle ─────────────────────────────────────────────────────────
28940
29148
  // Stimulus action: data-action="click->kanban#toggleColumn"
@@ -28949,14 +29157,9 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28949
29157
  const strip = wrapper.querySelector("[data-kanban-role='strip']");
28950
29158
  const body = wrapper.querySelector("[data-kanban-role='body']");
28951
29159
  if (!strip || !body) return;
28952
- const isCollapsed = wrapper.classList.contains("pu-kanban-column-collapsed");
28953
- if (isCollapsed) {
28954
- wrapper.classList.remove("pu-kanban-column-collapsed");
28955
- this.#saveCollapseState(key, false);
28956
- } else {
28957
- wrapper.classList.add("pu-kanban-column-collapsed");
28958
- this.#saveCollapseState(key, true);
28959
- }
29160
+ const nowCollapsed = wrapper.classList.toggle("pu-kanban-column-collapsed");
29161
+ const defaultCollapsed = wrapper.dataset.kanbanDefaultCollapsed === "true";
29162
+ this.#persistCollapse(key, nowCollapsed !== defaultCollapsed);
28960
29163
  }
28961
29164
  // ─── drag lifecycle ──────────────────────────────────────────────────────────
28962
29165
  #onDragStart(event) {
@@ -28995,6 +29198,23 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
28995
29198
  const toColumn = column.dataset.kanbanColumnKeyValue;
28996
29199
  const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")].filter((c4) => c4 !== this.draggedCard);
28997
29200
  const toIndex = this.#computeDropIndex(event.clientY, existingCards);
29201
+ const destWrapper = column.closest("[data-kanban-col]");
29202
+ if (destWrapper?.dataset.kanbanDropInteraction === "true" && fromColumn !== toColumn) {
29203
+ if (destWrapper.dataset.kanbanDropImmediate === "true") {
29204
+ const confirmMsg = destWrapper.dataset.kanbanDropConfirm;
29205
+ if (confirmMsg && !window.confirm(confirmMsg)) return;
29206
+ } else if (this.#openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex })) {
29207
+ return;
29208
+ }
29209
+ }
29210
+ this.#submitMove(recordId, { fromColumn, toColumn, toIndex });
29211
+ }
29212
+ // Direct move: POST {from_column, to_column, to_index} to the move endpoint
29213
+ // and feed the Turbo Stream response to Turbo. On success the server
29214
+ // re-renders the from + to column frames; on 422 it re-renders only the
29215
+ // source frame so the card snaps back — the controller never hand-manages
29216
+ // rollback state.
29217
+ async #submitMove(recordId, { fromColumn, toColumn, toIndex }) {
28998
29218
  const url = this.moveUrlTemplateValue.replace("__ID__", recordId);
28999
29219
  const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
29000
29220
  try {
@@ -29012,14 +29232,46 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
29012
29232
  }),
29013
29233
  credentials: "same-origin"
29014
29234
  });
29015
- const body = await response.text();
29016
- if (window.Turbo) {
29235
+ const contentType = response.headers.get("Content-Type") || "";
29236
+ const isTurboStream = contentType.includes("text/vnd.turbo-stream.html");
29237
+ if (isTurboStream && window.Turbo) {
29238
+ const body = await response.text();
29017
29239
  Turbo.renderStreamMessage(body);
29240
+ } else if (!response.ok) {
29241
+ console.error(`[kanban] move rejected (${response.status}); leaving card in place`);
29242
+ } else {
29243
+ console.warn("[kanban] move returned a non-stream response (session expired?); leaving card in place");
29018
29244
  }
29019
29245
  } catch (error2) {
29020
29246
  console.error("[kanban] move request failed:", error2);
29021
29247
  }
29022
29248
  }
29249
+ // ─── drop-interaction modal ────────────────────────────────────────────────
29250
+ //
29251
+ // A cross-column drop into an enter_interaction column opens the interaction's
29252
+ // form in the shared remote-modal frame instead of committing the move.
29253
+ //
29254
+ // Native HTML5 drag-and-drop never re-parents the card's DOM node — the card
29255
+ // physically stays in its SOURCE column throughout, so there is nothing to
29256
+ // snap back on cancel: dismissing the modal simply leaves the board as it
29257
+ // already is. On success the server's turbo-stream re-renders the kanban-col-*
29258
+ // frames and empties the remote_modal frame, so the board updates naturally.
29259
+ //
29260
+ // Returns true when the modal was opened (caller should stop), false when the
29261
+ // remote-modal frame is unavailable (caller falls back to the direct POST).
29262
+ #openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex }) {
29263
+ const template = destWrapper.dataset.kanbanDropFormUrlTemplate;
29264
+ const frame = document.getElementById("remote_modal");
29265
+ if (!frame || !template) return false;
29266
+ const params = new URLSearchParams({
29267
+ from_column: fromColumn,
29268
+ to_column: toColumn,
29269
+ to_index: toIndex
29270
+ });
29271
+ const url = `${template.replace("__ID__", recordId)}?${params.toString()}`;
29272
+ frame.src = url;
29273
+ return true;
29274
+ }
29023
29275
  #onDragEnd(_event) {
29024
29276
  this.#clearHighlights();
29025
29277
  this.#clearDropHints();
@@ -29050,32 +29302,38 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
29050
29302
  if (accepts === "none") return false;
29051
29303
  return accepts.split(",").map((k4) => k4.trim()).includes(sourceKey);
29052
29304
  }
29053
- // ─── collapse persistence ─────────────────────────────────────────────────────
29054
- // Applies localStorage collapse states to all column wrappers currently in
29055
- // the DOM. Called on connect() and implicitly after Turbo frame swaps
29056
- // because Stimulus re-connects the controller when the frame content changes.
29057
- #applyPersistedCollapseStates() {
29058
- this.element.querySelectorAll("[data-kanban-col]").forEach((wrapper) => {
29059
- const key = wrapper.dataset.kanbanCol;
29060
- const stored = localStorage.getItem(this.#storageKey(key));
29061
- if (stored === null) return;
29062
- const collapsed = stored === "1";
29063
- wrapper.classList.toggle("pu-kanban-column-collapsed", collapsed);
29064
- });
29065
- }
29066
- #saveCollapseState(key, collapsed) {
29067
- try {
29068
- localStorage.setItem(this.#storageKey(key), collapsed ? "1" : "0");
29069
- } catch {
29305
+ // ─── collapse persistence (cookie delta) ───────────────────────────────────
29306
+ //
29307
+ // The cookie stores ONLY the column keys whose state differs from the server
29308
+ // default, comma-joined (e.g. "todo,done"). The server reads it and renders
29309
+ // each column in the user's state directly, so there's no client re-apply and
29310
+ // no FOUC on any render path. The delta encoding keeps it compact and
29311
+ // self-trimming: a board at its defaults has no cookie, and toggling a column
29312
+ // back to default drops its key (and an empty set deletes the cookie).
29313
+ // `flipped` = the column's state now differs from its default.
29314
+ #persistCollapse(key, flipped) {
29315
+ const keys = new Set(this.#readCollapseCookie());
29316
+ if (flipped) keys.add(key);
29317
+ else keys.delete(key);
29318
+ this.#writeCollapseCookie([...keys]);
29319
+ }
29320
+ #readCollapseCookie() {
29321
+ const name = this.collapseCookieValue;
29322
+ if (!name) return [];
29323
+ const entry = document.cookie.split("; ").find((c4) => c4.startsWith(`${name}=`));
29324
+ if (!entry) return [];
29325
+ return decodeURIComponent(entry.slice(name.length + 1)).split(",").filter(Boolean);
29326
+ }
29327
+ #writeCollapseCookie(keys) {
29328
+ const name = this.collapseCookieValue;
29329
+ if (!name) return;
29330
+ const path = this.collapsePathValue || "/";
29331
+ if (keys.length === 0) {
29332
+ document.cookie = `${name}=; path=${path}; max-age=0; SameSite=Lax`;
29333
+ return;
29070
29334
  }
29071
- }
29072
- // Derives a unique localStorage key from the resource collection path so
29073
- // different boards (different resources / tenants) don't share state.
29074
- // The move URL template is "/path/__ID__/kanban_move"; strip the suffix to
29075
- // recover the collection path.
29076
- #storageKey(key) {
29077
- const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "");
29078
- return `pu-kanban:${path}:${key}:collapsed`;
29335
+ const value = encodeURIComponent(keys.join(","));
29336
+ document.cookie = `${name}=${value}; path=${path}; max-age=${60 * 60 * 24 * 180}; SameSite=Lax`;
29079
29337
  }
29080
29338
  // ─── helpers ─────────────────────────────────────────────────────────────────
29081
29339
  // Returns the 0-based insertion index within the destination column by
@@ -29099,6 +29357,24 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
29099
29357
  }
29100
29358
  };
29101
29359
 
29360
+ // src/js/controllers/currency_input_controller.js
29361
+ var currency_input_controller_default = class extends Controller {
29362
+ static targets = ["prefix", "field"];
29363
+ // Space between the prefix and the first digit, in px.
29364
+ static values = { gap: { type: Number, default: 6 } };
29365
+ connect() {
29366
+ this.#pad();
29367
+ document.fonts.ready.then(() => this.#pad());
29368
+ }
29369
+ #pad() {
29370
+ this.fieldTarget.style.setProperty(
29371
+ "padding-left",
29372
+ `${this.prefixTarget.offsetWidth + this.gapValue}px`,
29373
+ "important"
29374
+ );
29375
+ }
29376
+ };
29377
+
29102
29378
  // src/js/controllers/register_controllers.js
29103
29379
  function register_controllers_default(application2) {
29104
29380
  application2.register("password-visibility", password_visibility_controller_default);
@@ -29139,6 +29415,7 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
29139
29415
  application2.register("dirty-form-guard", dirty_form_guard_controller_default);
29140
29416
  application2.register("wizard", wizard_controller_default);
29141
29417
  application2.register("kanban", kanban_controller_default);
29418
+ application2.register("currency-input", currency_input_controller_default);
29142
29419
  }
29143
29420
 
29144
29421
  // src/js/turbo/turbo_actions.js