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
@@ -3,6 +3,7 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="intl-tel-input"
4
4
  export default class extends Controller {
5
5
  static targets = ["input"]
6
+ static values = { options: Object }
6
7
 
7
8
  connect() {
8
9
  }
@@ -49,10 +50,13 @@ export default class extends Controller {
49
50
  }
50
51
 
51
52
  #buildOptions() {
53
+ // Defaults first; the definition-supplied `options` value (e.g.
54
+ // { initialCountry: "gh", strictMode: false }) is spread last so it wins.
52
55
  return {
53
56
  strictMode: true,
54
57
  hiddenInput: () => ({ phone: this.inputTarget.attributes.name.value }),
55
58
  loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js",
59
+ ...this.optionsValue,
56
60
  }
57
61
  }
58
62
  }
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { morphTurboFrameElements } from "@hotwired/turbo"
2
3
 
3
4
  // Connects to data-controller="kanban"
4
5
  //
@@ -37,15 +38,31 @@ import { Controller } from "@hotwired/stimulus"
37
38
  //
38
39
  // ## Collapse toggle
39
40
  //
40
- // toggleColumn reads data-kanban-column-key from the clicked button, finds
41
- // the matching [data-kanban-col] wrapper, and flips the CSS class
42
- // `pu-kanban-column-collapsed` on it. CSS handles show/hide of strip vs body.
43
- // The per-column state is persisted in localStorage keyed by the resource
44
- // path + column key so the preference survives page reloads.
41
+ // toggleColumn reads data-kanban-column-key from the clicked button, finds the
42
+ // matching [data-kanban-col] wrapper, and flips `pu-kanban-column-collapsed` on
43
+ // it for instant feedback. CSS handles show/hide of strip vs body.
45
44
  //
46
- // On connect() the controller reads all persisted states and applies them
47
- // before the first paint (wrappers are present in the DOM when the turbo-frame
48
- // content loads; Stimulus's MutationObserver reconnects after each frame swap).
45
+ // The choice is persisted in a per-board cookie (name/path supplied via the
46
+ // collapse-cookie / collapse-path values) storing ONLY columns whose state
47
+ // differs from their server default read from data-kanban-default-collapsed.
48
+ // Because the SERVER reads that cookie and renders each column in the user's
49
+ // state, there is no client re-apply on any render path (lazy load, search
50
+ // morph, move stream) and therefore no collapse FOUC. The delta encoding keeps
51
+ // the cookie compact and self-trimming (default board → no cookie).
52
+ //
53
+ // ## Frozen board (search / filter / scope)
54
+ //
55
+ // The board wrapper is `data-turbo-permanent`, so it survives index
56
+ // navigations intact rather than being re-rendered as empty lazy shells (which
57
+ // blanked the columns on every search keystroke). Turbo transplants the
58
+ // permanent element into the new page, which disconnects→reconnects this
59
+ // controller — so connect() runs on every nav, and the frozen frames still
60
+ // carry the PREVIOUS URL's src. #syncColumnsToUrl (called from connect() and on
61
+ // every `turbo:load`) reconciles each frame's src with the current URL,
62
+ // reloading only the frames that differ; `turbo:before-frame-render` upgrades
63
+ // those reloads to a MORPH so cards diff in place instead of blanking. The sync
64
+ // is stateless (frame-src-vs-URL, no "last synced" flag) precisely because the
65
+ // reconnect would reset any such flag before it could be used.
49
66
  //
50
67
  // ## Drop hints
51
68
  //
@@ -72,7 +89,7 @@ import { Controller } from "@hotwired/stimulus"
72
89
  // controller never hand-manages rollback state.
73
90
  // 4. dragend — clean up opacity + highlights + drop-hint classes.
74
91
  export default class extends Controller {
75
- static values = { moveUrlTemplate: String }
92
+ static values = { moveUrlTemplate: String, collapseCookie: String, collapsePath: String }
76
93
  static targets = ["column"]
77
94
 
78
95
  connect() {
@@ -90,9 +107,66 @@ export default class extends Controller {
90
107
  this.element.addEventListener("drop", this.onDrop)
91
108
  this.element.addEventListener("dragend", this.onDragEnd)
92
109
 
93
- // Apply any persisted collapse states from localStorage so columns
94
- // retain the user's preference across page reloads.
95
- this.#applyPersistedCollapseStates()
110
+ // Preserve horizontal scroll across navs AND full page reloads. The browser
111
+ // never restores an inner container's scroll, and neither a Turbo reattach
112
+ // (permanent board detached→reattached, scrollLeft reset to 0) nor an F5
113
+ // (fresh DOM) keeps it — so we persist it in sessionStorage, keyed per board,
114
+ // and restore it below. scrollTarget is the in-memory copy (seeded from
115
+ // storage on every connect, so it survives reconnects too).
116
+ this.scrollTarget = this.#readStoredScroll()
117
+
118
+ this.captureScroll = this.#captureScroll.bind(this)
119
+
120
+ // Trailing debounce: a scroll event only (re)arms the timer — it reads NO
121
+ // layout, so it can't force a reflow / stutter. The capture reads
122
+ // scrollLeft/scrollWidth once, after scrolling settles.
123
+ this.onBoardScroll = () => {
124
+ if (this.restoringScroll) return
125
+ clearTimeout(this.scrollSaveTimer)
126
+ this.scrollSaveTimer = setTimeout(this.captureScroll, 120)
127
+ }
128
+ this.element.addEventListener("scroll", this.onBoardScroll, { passive: true })
129
+
130
+ // A genuine scroll gesture during the restore window means the user has taken
131
+ // over — stop pinning so we don't fight them.
132
+ this.onUserScrollIntent = () => this.#endScrollRestore()
133
+ this.element.addEventListener("wheel", this.onUserScrollIntent, { passive: true })
134
+ this.element.addEventListener("touchmove", this.onUserScrollIntent, { passive: true })
135
+
136
+ // Capture immediately before an F5 / cross-document nav unloads us, so a
137
+ // reload mid-scroll (before the debounce fires) still saves the position.
138
+ this.onPageHide = this.captureScroll
139
+ window.addEventListener("pagehide", this.onPageHide)
140
+
141
+ // ── Frozen-board sync (see class header) ──
142
+ this.onTurboLoad = this.#syncColumnsToUrl.bind(this)
143
+ this.onBeforeFrameRender = this.#onBeforeFrameRender.bind(this)
144
+ this.onFrameRender = this.#onFrameRender.bind(this)
145
+ this.onBeforeStreamRender = this.#onBeforeStreamRender.bind(this)
146
+
147
+ // After each navigation, reconcile the column frames' src with the new URL.
148
+ document.addEventListener("turbo:load", this.onTurboLoad)
149
+ // Force a MORPH render on column-frame reloads (a plain src change would
150
+ // blank→fill; morphing diffs cards in place).
151
+ document.addEventListener("turbo:before-frame-render", this.onBeforeFrameRender)
152
+ // turbo:frame-render (nav reload) and turbo:before-stream-render (move /
153
+ // realtime) are hooked only to keep the horizontal scroll pinned as columns
154
+ // re-render — collapse state no longer needs re-applying, because the server
155
+ // renders each column in the user's cookie-persisted state directly.
156
+ document.addEventListener("turbo:frame-render", this.onFrameRender)
157
+ document.addEventListener("turbo:before-stream-render", this.onBeforeStreamRender)
158
+
159
+ // Reconcile immediately: when Turbo transplants the permanent board it
160
+ // disconnects→reconnects this controller, so connect() itself runs on every
161
+ // nav. A stale frame (src carrying the previous URL's params) is reloaded
162
+ // here; a fresh board (frames already matching the URL) is a no-op.
163
+ this.#syncColumnsToUrl()
164
+
165
+ // connect() runs right after reattach — put the horizontal scroll back.
166
+ this.#restoreScrollLeft()
167
+
168
+ // Refresh cached columns after a write (see #reloadAfterWrite).
169
+ this.#reloadAfterWrite()
96
170
  }
97
171
 
98
172
  disconnect() {
@@ -101,6 +175,231 @@ export default class extends Controller {
101
175
  this.element.removeEventListener("dragleave", this.onDragLeave)
102
176
  this.element.removeEventListener("drop", this.onDrop)
103
177
  this.element.removeEventListener("dragend", this.onDragEnd)
178
+
179
+ // Capture before we lose this instance to a Turbo reconnect, so the next
180
+ // connect reads an up-to-date position.
181
+ this.#captureScroll()
182
+ this.element.removeEventListener("scroll", this.onBoardScroll)
183
+ this.element.removeEventListener("wheel", this.onUserScrollIntent)
184
+ this.element.removeEventListener("touchmove", this.onUserScrollIntent)
185
+ window.removeEventListener("pagehide", this.onPageHide)
186
+ clearTimeout(this.restoreScrollTimer)
187
+ clearTimeout(this.scrollSaveTimer)
188
+
189
+ document.removeEventListener("turbo:load", this.onTurboLoad)
190
+ document.removeEventListener("turbo:before-frame-render", this.onBeforeFrameRender)
191
+ document.removeEventListener("turbo:frame-render", this.onFrameRender)
192
+ document.removeEventListener("turbo:before-stream-render", this.onBeforeStreamRender)
193
+ }
194
+
195
+ // ─── Frozen-board URL sync ────────────────────────────────────────────────────
196
+
197
+ // Reconcile every column frame's src with the current URL's board params
198
+ // (q / scope / sort). The board is frozen (data-turbo-permanent), so its
199
+ // frames keep whatever src they were last loaded with; this is what reflects a
200
+ // new search / filter / scope into the columns.
201
+ //
202
+ // STATELESS by design: we compare each frame's actual src against the src the
203
+ // current URL implies and reload only the ones that differ. We deliberately do
204
+ // NOT track "last synced URL" — Turbo reconnects this controller on every nav
205
+ // (the permanent board is transplanted), so any per-connect state would be
206
+ // reset to the new URL before we could diff against it, and the sync would
207
+ // never fire. Comparing frame-src-vs-URL has no such blind spot and is
208
+ // self-limiting: once a frame matches the URL, it won't reload again.
209
+ // A create/update/destroy that returns to the board redirects to a URL the
210
+ // server tagged with kanban_reload=1 (see KanbanActions#kanban_reload_url).
211
+ // The board is data-turbo-permanent, so its already-loaded column frames are
212
+ // kept as-is on arrival — stale (missing a new card, still showing a deleted
213
+ // one). Force every column to re-fetch, then strip the marker so a later
214
+ // reload / back-nav doesn't re-trigger it. The restore window opened by
215
+ // #restoreScrollLeft keeps the scroll pinned as the fresh frames render.
216
+ #reloadAfterWrite() {
217
+ const url = new URL(window.location.href)
218
+ if (!url.searchParams.has("kanban_reload")) return
219
+ url.searchParams.delete("kanban_reload")
220
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`)
221
+ this.#columnFrames().forEach(frame => frame.reload())
222
+ }
223
+
224
+ #syncColumnsToUrl() {
225
+ this.#columnFrames().forEach(frame => {
226
+ const desired = this.#columnFrameSrc(frame.dataset.kanbanColFrame)
227
+ const current = frame.getAttribute("src")
228
+ if (current && this.#canonicalUrl(current) === this.#canonicalUrl(desired)) return
229
+ // Assigning src reloads the frame; #onBeforeFrameRender upgrades that
230
+ // reload to a morph so the cards diff in place.
231
+ frame.src = desired
232
+ })
233
+ }
234
+
235
+ // The src a column frame should carry for the current URL: the page's query
236
+ // params with view=kanban + column=<key> forced on. Mirrors the server's
237
+ // Kanban::Resource#column_frame_src so a freshly-rendered board's frames
238
+ // already match (no spurious reload).
239
+ #columnFrameSrc(key) {
240
+ const params = new URLSearchParams(window.location.search)
241
+ params.set("view", "kanban")
242
+ params.set("column", key)
243
+ return `${window.location.pathname}?${params.toString()}`
244
+ }
245
+
246
+ // Order-independent identity for a frame src: path + alphabetically sorted
247
+ // query params. The server serialises params sorted (Hash#to_query) while
248
+ // URLSearchParams preserves insertion order, so raw string comparison would
249
+ // report a false difference and reload every frame on every nav.
250
+ #canonicalUrl(src) {
251
+ const url = new URL(src, window.location.origin)
252
+ url.searchParams.sort()
253
+ return `${url.pathname}?${url.searchParams.toString()}`
254
+ }
255
+
256
+ // Turbo fires this before a frame renders fetched content. For our column
257
+ // frames we swap in a morph render so the reload diffs the card list rather
258
+ // than replacing it (which would blank the column). Uses Turbo's own frame
259
+ // morph so before/after hooks (turbo:before-frame-morph) still fire.
260
+ #onBeforeFrameRender(event) {
261
+ if (!this.#isColumnFrame(event.target)) return
262
+ // Diff the card list in place instead of blank→fill. Collapse state rides
263
+ // along correctly because the server rendered the fetched frame in the
264
+ // user's state, so morphing to it keeps that state — no re-apply needed.
265
+ event.detail.render = (currentElement, newElement) =>
266
+ morphTurboFrameElements(currentElement, newElement)
267
+ }
268
+
269
+ #onFrameRender(event) {
270
+ if (!this.#isColumnFrame(event.target)) return
271
+ // Each column render can change the board's width and clamp scrollLeft. On a
272
+ // full reload the columns lazy-load one at a time AFTER connect, so keep
273
+ // pinning — and push the restore window out — with every frame that lands.
274
+ if (this.restoringScroll) {
275
+ this.#pinScroll()
276
+ this.#bumpRestoreTimer()
277
+ }
278
+ }
279
+
280
+ // Apply the saved horizontal position: to the live max when the user was at
281
+ // the end (so it tracks late width changes), otherwise to the exact offset.
282
+ // No-op when nothing meaningful was saved so it can't yank a fresh board to 0.
283
+ #pinScroll() {
284
+ const t = this.scrollTarget
285
+ if (!t || (!t.l && !t.e)) return
286
+ this.element.scrollLeft = t.e ? this.element.scrollWidth : t.l
287
+ }
288
+
289
+ // Re-apply the saved horizontal scroll across a window that extends with each
290
+ // column render, because renders (morph on nav, replace on move, lazy load on
291
+ // full reload) settle their width late and each settle can clamp scrollLeft
292
+ // back toward 0. Used by the reattach, turbo-stream, and initial-load paths.
293
+ #scheduleScrollRestore() {
294
+ if (!this.scrollTarget || (!this.scrollTarget.l && !this.scrollTarget.e)) return
295
+ this.restoringScroll = true
296
+ this.#pinScroll()
297
+ requestAnimationFrame(() => this.#pinScroll())
298
+ this.#bumpRestoreTimer()
299
+ }
300
+
301
+ // (Re)arm the timer that ends the restore window. Reset on every column render
302
+ // so the window lives ~400ms past the LAST column to settle — enough for slow
303
+ // lazy frames on a fresh load without pinning forever.
304
+ #bumpRestoreTimer() {
305
+ clearTimeout(this.restoreScrollTimer)
306
+ this.restoreScrollTimer = setTimeout(() => this.#endScrollRestore(), 400)
307
+ }
308
+
309
+ #endScrollRestore() {
310
+ if (!this.restoringScroll) return
311
+ this.#pinScroll()
312
+ this.restoringScroll = false
313
+ clearTimeout(this.restoreScrollTimer)
314
+ }
315
+
316
+ // Restore on connect — covers Turbo reattach (search/filter) and a full page
317
+ // reload (F5), since scrollTarget is seeded from sessionStorage either way.
318
+ #restoreScrollLeft() {
319
+ this.#scheduleScrollRestore()
320
+ }
321
+
322
+ // ── scroll persistence (sessionStorage) ──
323
+ // Per-tab and auto-cleared on tab close, so it can't accumulate. Keyed by the
324
+ // board's collection path (tenant + resource scoped) via the move template.
325
+
326
+ #scrollKey() {
327
+ const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "")
328
+ return `pu-kanban-scroll:${path}`
329
+ }
330
+
331
+ #readStoredScroll() {
332
+ try {
333
+ const raw = sessionStorage.getItem(this.#scrollKey())
334
+ return raw ? JSON.parse(raw) : null
335
+ } catch {
336
+ return null
337
+ }
338
+ }
339
+
340
+ // Read the live position (unless a restore currently owns scrollTarget) and
341
+ // persist it. Runs on the scroll debounce, on pagehide, and on disconnect —
342
+ // never on the raw scroll event, so the layout read can't stutter scrolling.
343
+ #captureScroll() {
344
+ const el = this.element
345
+ // Skip the read when the board has no layout: on a nav teardown the element
346
+ // is detached, so scrollLeft/scrollWidth/clientWidth all read 0 — which would
347
+ // compute a bogus "at-end" (0 >= -2) and pin the next restore to the far end.
348
+ // The last in-memory target already reflects the user's real position, so
349
+ // keep it. `e` also requires genuine overflow, never a zero-width board.
350
+ if (!this.restoringScroll && el.clientWidth > 0) {
351
+ const maxScroll = el.scrollWidth - el.clientWidth
352
+ this.scrollTarget = {
353
+ l: el.scrollLeft,
354
+ e: maxScroll > 0 && el.scrollLeft >= maxScroll - 2
355
+ }
356
+ }
357
+ if (!this.scrollTarget) return
358
+ try {
359
+ sessionStorage.setItem(this.#scrollKey(), JSON.stringify(this.scrollTarget))
360
+ } catch { /* private mode / quota — in-memory target still works this session */ }
361
+ }
362
+
363
+ // A move / realtime update re-renders columns via turbo-stream (replace). The
364
+ // server renders them in the user's collapse state, so we only need to protect
365
+ // the horizontal scroll: freeze tracking BEFORE the swap (replacing a column
366
+ // briefly removes it, narrowing the board so scrollLeft clamps toward 0 — that
367
+ // clamp would otherwise be saved as the new position, a visible jump at the
368
+ // far end), then restore once the columns are back.
369
+ #onBeforeStreamRender(event) {
370
+ // Only interfere with streams that target one of THIS board's column
371
+ // frames. Any other stream (a redirect, a flash append, another board's
372
+ // update, the remote_modal empty on drop-interaction success) must render
373
+ // untouched so unrelated stream actions don't surface through this wrapper.
374
+ if (!this.#streamTargetsColumn(event.target)) return
375
+
376
+ const render = event.detail.render
377
+ event.detail.render = async (streamElement) => {
378
+ this.restoringScroll = true
379
+ await render(streamElement)
380
+ this.#scheduleScrollRestore()
381
+ }
382
+ }
383
+
384
+ // True when the <turbo-stream> element targets a column frame contained by
385
+ // this board — via its `target` (frame id) or `targets` (CSS selector).
386
+ #streamTargetsColumn(streamElement) {
387
+ if (!streamElement) return false
388
+ const target = streamElement.getAttribute("target")
389
+ if (target) return this.#isColumnFrame(document.getElementById(target))
390
+ const targets = streamElement.getAttribute("targets")
391
+ if (targets) {
392
+ return [...document.querySelectorAll(targets)].some(el => this.#isColumnFrame(el))
393
+ }
394
+ return false
395
+ }
396
+
397
+ #columnFrames() {
398
+ return this.element.querySelectorAll("turbo-frame[data-kanban-col-frame]")
399
+ }
400
+
401
+ #isColumnFrame(el) {
402
+ return el?.matches?.("turbo-frame[data-kanban-col-frame]") && this.element.contains(el)
104
403
  }
105
404
 
106
405
  // ─── Collapse toggle ─────────────────────────────────────────────────────────
@@ -121,16 +420,12 @@ export default class extends Controller {
121
420
  if (!strip || !body) return
122
421
 
123
422
  // `pu-kanban-column-collapsed` on the wrapper is what CSS uses to decide
124
- // which half to show. Toggling the class is the only state mutation.
125
- const isCollapsed = wrapper.classList.contains("pu-kanban-column-collapsed")
126
-
127
- if (isCollapsed) {
128
- wrapper.classList.remove("pu-kanban-column-collapsed")
129
- this.#saveCollapseState(key, false)
130
- } else {
131
- wrapper.classList.add("pu-kanban-column-collapsed")
132
- this.#saveCollapseState(key, true)
133
- }
423
+ // which half to show. Flip it for instant feedback, then persist the choice
424
+ // as a delta from the column's server default so the next render (from the
425
+ // server) comes back in this same state.
426
+ const nowCollapsed = wrapper.classList.toggle("pu-kanban-column-collapsed")
427
+ const defaultCollapsed = wrapper.dataset.kanbanDefaultCollapsed === "true"
428
+ this.#persistCollapse(key, nowCollapsed !== defaultCollapsed)
134
429
  }
135
430
 
136
431
  // ─── drag lifecycle ──────────────────────────────────────────────────────────
@@ -200,6 +495,39 @@ export default class extends Controller {
200
495
  .filter(c => c !== this.draggedCard)
201
496
 
202
497
  const toIndex = this.#computeDropIndex(event.clientY, existingCards)
498
+
499
+ // Columns that declare an enter_interaction, on a CROSS-column drop:
500
+ // • immediate (input-less) interaction → commit directly via the normal
501
+ // POST (the server runs the interaction with no params); honour an
502
+ // optional confirmation first. No empty modal.
503
+ // • input-collecting interaction → open the interaction modal; the
504
+ // card is left in place until the modal resolves.
505
+ // Same-column reorders (from == toColumn) fall through to the direct POST,
506
+ // which the server treats as positioning-only — mirroring the server's rule.
507
+ const destWrapper = column.closest("[data-kanban-col]")
508
+ if (destWrapper?.dataset.kanbanDropInteraction === "true" && fromColumn !== toColumn) {
509
+ if (destWrapper.dataset.kanbanDropImmediate === "true") {
510
+ const confirmMsg = destWrapper.dataset.kanbanDropConfirm
511
+ // Abort on decline — the card was never moved in the DOM, so there is
512
+ // nothing to restore.
513
+ if (confirmMsg && !window.confirm(confirmMsg)) return
514
+ // fall through to #submitMove (direct commit, no modal)
515
+ } else if (this.#openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex })) {
516
+ return
517
+ }
518
+ // Immediate (confirmed), or the modal frame was unavailable — fall through
519
+ // to the direct POST so a drop is never silently dropped.
520
+ }
521
+
522
+ this.#submitMove(recordId, { fromColumn, toColumn, toIndex })
523
+ }
524
+
525
+ // Direct move: POST {from_column, to_column, to_index} to the move endpoint
526
+ // and feed the Turbo Stream response to Turbo. On success the server
527
+ // re-renders the from + to column frames; on 422 it re-renders only the
528
+ // source frame so the card snaps back — the controller never hand-manages
529
+ // rollback state.
530
+ async #submitMove(recordId, { fromColumn, toColumn, toIndex }) {
203
531
  const url = this.moveUrlTemplateValue.replace("__ID__", recordId)
204
532
  const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
205
533
 
@@ -219,18 +547,71 @@ export default class extends Controller {
219
547
  credentials: "same-origin",
220
548
  })
221
549
 
222
- const body = await response.text()
223
- // Turbo.renderStreamMessage processes <turbo-stream> elements in the
224
- // response body. On success this re-renders the from + to column frames;
225
- // on 422 it re-renders only the source frame, snapping the card back.
226
- if (window.Turbo) {
550
+ // Only feed genuine Turbo Stream responses to Turbo. A rejected move can
551
+ // come back as a plain HTML document — e.g. a 403 Unauthorized renders an
552
+ // error page, not a stream. Passing that to renderStreamMessage makes
553
+ // Turbo morph the error page's markup into the board (the "page turns red"
554
+ // bug). Native HTML5 DnD never re-parented the card, so on a non-stream
555
+ // rejection there is nothing to snap back — the board already shows the
556
+ // card in its source column.
557
+ const contentType = response.headers.get("Content-Type") || ""
558
+ const isTurboStream = contentType.includes("text/vnd.turbo-stream.html")
559
+
560
+ if (isTurboStream && window.Turbo) {
561
+ const body = await response.text()
562
+ // #onBeforeStreamRender re-asserts the persisted collapse state for each
563
+ // column the stream re-renders (synchronously, before paint), so the
564
+ // move doesn't reset a column the user expanded/collapsed.
227
565
  Turbo.renderStreamMessage(body)
566
+ } else if (!response.ok) {
567
+ console.error(`[kanban] move rejected (${response.status}); leaving card in place`)
568
+ } else {
569
+ // 2xx but NOT a Turbo Stream. The most common cause is an auth boundary
570
+ // that 302-redirected to a login page, which fetch transparently follows,
571
+ // yielding a 200 HTML document — so the move silently did nothing. Don't
572
+ // swallow it: log it so an expired session is diagnosable. The card is left
573
+ // in place (native DnD never re-parented it).
574
+ console.warn("[kanban] move returned a non-stream response (session expired?); leaving card in place")
228
575
  }
229
576
  } catch (error) {
230
577
  console.error("[kanban] move request failed:", error)
231
578
  }
232
579
  }
233
580
 
581
+ // ─── drop-interaction modal ────────────────────────────────────────────────
582
+ //
583
+ // A cross-column drop into an enter_interaction column opens the interaction's
584
+ // form in the shared remote-modal frame instead of committing the move.
585
+ //
586
+ // Native HTML5 drag-and-drop never re-parents the card's DOM node — the card
587
+ // physically stays in its SOURCE column throughout, so there is nothing to
588
+ // snap back on cancel: dismissing the modal simply leaves the board as it
589
+ // already is. On success the server's turbo-stream re-renders the kanban-col-*
590
+ // frames and empties the remote_modal frame, so the board updates naturally.
591
+ //
592
+ // Returns true when the modal was opened (caller should stop), false when the
593
+ // remote-modal frame is unavailable (caller falls back to the direct POST).
594
+ #openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex }) {
595
+ const template = destWrapper.dataset.kanbanDropFormUrlTemplate
596
+ // Plutonium::REMOTE_MODAL_FRAME — rendered once by the layout, outside this
597
+ // (permanent) board element.
598
+ const frame = document.getElementById("remote_modal")
599
+ if (!frame || !template) return false
600
+
601
+ const params = new URLSearchParams({
602
+ from_column: fromColumn,
603
+ to_column: toColumn,
604
+ to_index: toIndex,
605
+ })
606
+ const url = `${template.replace("__ID__", recordId)}?${params.toString()}`
607
+
608
+ // Point the modal frame at the interaction form. On success the server
609
+ // empties this frame; on 422 it re-renders the form (errors + preserved
610
+ // hidden fields) in place.
611
+ frame.src = url
612
+ return true
613
+ }
614
+
234
615
  #onDragEnd(_event) {
235
616
  this.#clearHighlights()
236
617
  this.#clearDropHints()
@@ -270,38 +651,44 @@ export default class extends Controller {
270
651
  return accepts.split(",").map(k => k.trim()).includes(sourceKey)
271
652
  }
272
653
 
273
- // ─── collapse persistence ─────────────────────────────────────────────────────
274
-
275
- // Applies localStorage collapse states to all column wrappers currently in
276
- // the DOM. Called on connect() and implicitly after Turbo frame swaps
277
- // because Stimulus re-connects the controller when the frame content changes.
278
- #applyPersistedCollapseStates() {
279
- this.element.querySelectorAll("[data-kanban-col]").forEach(wrapper => {
280
- const key = wrapper.dataset.kanbanCol
281
- const stored = localStorage.getItem(this.#storageKey(key))
282
- if (stored === null) return // No stored preference; use server-rendered initial state.
283
-
284
- const collapsed = stored === "1"
285
- wrapper.classList.toggle("pu-kanban-column-collapsed", collapsed)
286
- })
654
+ // ─── collapse persistence (cookie delta) ───────────────────────────────────
655
+ //
656
+ // The cookie stores ONLY the column keys whose state differs from the server
657
+ // default, comma-joined (e.g. "todo,done"). The server reads it and renders
658
+ // each column in the user's state directly, so there's no client re-apply and
659
+ // no FOUC on any render path. The delta encoding keeps it compact and
660
+ // self-trimming: a board at its defaults has no cookie, and toggling a column
661
+ // back to default drops its key (and an empty set deletes the cookie).
662
+
663
+ // `flipped` = the column's state now differs from its default.
664
+ #persistCollapse(key, flipped) {
665
+ const keys = new Set(this.#readCollapseCookie())
666
+ if (flipped) keys.add(key)
667
+ else keys.delete(key)
668
+ this.#writeCollapseCookie([...keys])
287
669
  }
288
670
 
289
- #saveCollapseState(key, collapsed) {
290
- // Safari private-browsing reports a 0-byte quota and throws
291
- // QuotaExceededError on setItem. Swallow it so the toggle still works
292
- // visually it just won't persist across reloads in that mode.
293
- try {
294
- localStorage.setItem(this.#storageKey(key), collapsed ? "1" : "0")
295
- } catch { /* private browsing: toggle still works, just won't persist */ }
671
+ #readCollapseCookie() {
672
+ const name = this.collapseCookieValue
673
+ if (!name) return []
674
+ const entry = document.cookie.split("; ").find(c => c.startsWith(`${name}=`))
675
+ if (!entry) return []
676
+ return decodeURIComponent(entry.slice(name.length + 1)).split(",").filter(Boolean)
296
677
  }
297
678
 
298
- // Derives a unique localStorage key from the resource collection path so
299
- // different boards (different resources / tenants) don't share state.
300
- // The move URL template is "/path/__ID__/kanban_move"; strip the suffix to
301
- // recover the collection path.
302
- #storageKey(key) {
303
- const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "")
304
- return `pu-kanban:${path}:${key}:collapsed`
679
+ #writeCollapseCookie(keys) {
680
+ const name = this.collapseCookieValue
681
+ if (!name) return
682
+ const path = this.collapsePathValue || "/"
683
+ if (keys.length === 0) {
684
+ // Board back to all-defaults — drop the cookie entirely.
685
+ document.cookie = `${name}=; path=${path}; max-age=0; SameSite=Lax`
686
+ return
687
+ }
688
+ const value = encodeURIComponent(keys.join(","))
689
+ // ~6 months: refreshed on every toggle, so boards in active use persist and
690
+ // stale ones expire on their own — another guard against unbounded growth.
691
+ document.cookie = `${name}=${value}; path=${path}; max-age=${60 * 60 * 24 * 180}; SameSite=Lax`
305
692
  }
306
693
 
307
694
  // ─── helpers ─────────────────────────────────────────────────────────────────
@@ -37,6 +37,7 @@ import AutosubmitController from "./autosubmit_controller.js"
37
37
  import DirtyFormGuardController from "./dirty_form_guard_controller.js"
38
38
  import WizardController from "./wizard_controller.js"
39
39
  import KanbanController from "./kanban_controller.js"
40
+ import CurrencyInputController from "./currency_input_controller.js"
40
41
 
41
42
  export default function (application) {
42
43
  // Register controllers here
@@ -78,4 +79,5 @@ export default function (application) {
78
79
  application.register("dirty-form-guard", DirtyFormGuardController)
79
80
  application.register("wizard", WizardController)
80
81
  application.register("kanban", KanbanController)
82
+ application.register("currency-input", CurrencyInputController)
81
83
  }