plutonium 0.60.5 → 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 (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. metadata +113 -2
@@ -0,0 +1,717 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { morphTurboFrameElements } from "@hotwired/turbo"
3
+
4
+ // Connects to data-controller="kanban"
5
+ //
6
+ // Enables drag-and-drop reordering of kanban cards across columns using the
7
+ // browser's native HTML5 drag-and-drop API (no additional npm dependency).
8
+ //
9
+ // ## DOM contract
10
+ //
11
+ // Board wrapper (this element):
12
+ // data-controller="kanban"
13
+ // data-kanban-move-url-template-value="/path/__ID__/kanban_move"
14
+ // — the collection path with an __ID__ placeholder; the controller
15
+ // substitutes the dragged card's record id at drop time.
16
+ //
17
+ // Column wrapper (rendered by Kanban::Column as the turbo-frame body):
18
+ // data-kanban-col="<key>" — unique wrapper identifier for JS
19
+ // data-kanban-accepts="all|none|<key>,<key>,…"
20
+ // — "all": any card may be dropped here
21
+ // — "none": no card may be dropped here
22
+ // — comma list: only cards whose source column key is in the list
23
+ // data-kanban-locked="true|false"
24
+ // — true: cards in this column cannot be dragged out of it
25
+ //
26
+ // Column drop zone (rendered by Kanban::Column inside each turbo-frame):
27
+ // data-kanban-target="column"
28
+ // data-kanban-column-key-value="<key>"
29
+ //
30
+ // Draggable card (rendered by Kanban::Card):
31
+ // draggable="true"
32
+ // data-kanban-record-id="<id>"
33
+ // data-kanban-column-key="<source-column-key>"
34
+ //
35
+ // Column toggle control (button inside the strip / expanded header):
36
+ // data-action="click->kanban#toggleColumn"
37
+ // data-kanban-column-key="<key>"
38
+ //
39
+ // ## Collapse toggle
40
+ //
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.
44
+ //
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.
66
+ //
67
+ // ## Drop hints
68
+ //
69
+ // On dragstart:
70
+ // 1. Determine whether the source column is locked (cards cannot leave).
71
+ // 2. For each column wrapper, compute whether a drop would be accepted and
72
+ // add the CSS class `pu-kanban-no-drop` to wrappers that would reject.
73
+ // 3. Also suppress the browser's `dragover` preventDefault() for no-drop
74
+ // columns so the native "no entry" cursor shows.
75
+ // On dragend: clear all hint classes.
76
+ //
77
+ // ## Move flow
78
+ //
79
+ // 1. dragstart — record which card was grabbed and apply an opacity hint.
80
+ // 2. dragover — highlight the target column drop zone; suppress the browser's
81
+ // "forbidden" cursor by calling preventDefault(). For columns
82
+ // marked pu-kanban-no-drop we skip preventDefault so the native
83
+ // "no entry" cursor shows instead.
84
+ // 3. drop — compute to_index (vertical cursor position within the column),
85
+ // POST {from_column, to_column, to_index} to the move endpoint,
86
+ // and feed the Turbo Stream response to Turbo.renderStreamMessage.
87
+ // On success the server re-renders both column frames; on 422 it
88
+ // re-renders only the source column so the card snaps back — the
89
+ // controller never hand-manages rollback state.
90
+ // 4. dragend — clean up opacity + highlights + drop-hint classes.
91
+ export default class extends Controller {
92
+ static values = { moveUrlTemplate: String, collapseCookie: String, collapsePath: String }
93
+ static targets = ["column"]
94
+
95
+ connect() {
96
+ this.draggedCard = null
97
+
98
+ this.onDragStart = this.#onDragStart.bind(this)
99
+ this.onDragOver = this.#onDragOver.bind(this)
100
+ this.onDragLeave = this.#onDragLeave.bind(this)
101
+ this.onDrop = this.#onDrop.bind(this)
102
+ this.onDragEnd = this.#onDragEnd.bind(this)
103
+
104
+ this.element.addEventListener("dragstart", this.onDragStart)
105
+ this.element.addEventListener("dragover", this.onDragOver)
106
+ this.element.addEventListener("dragleave", this.onDragLeave)
107
+ this.element.addEventListener("drop", this.onDrop)
108
+ this.element.addEventListener("dragend", this.onDragEnd)
109
+
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()
170
+ }
171
+
172
+ disconnect() {
173
+ this.element.removeEventListener("dragstart", this.onDragStart)
174
+ this.element.removeEventListener("dragover", this.onDragOver)
175
+ this.element.removeEventListener("dragleave", this.onDragLeave)
176
+ this.element.removeEventListener("drop", this.onDrop)
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)
403
+ }
404
+
405
+ // ─── Collapse toggle ─────────────────────────────────────────────────────────
406
+
407
+ // Stimulus action: data-action="click->kanban#toggleColumn"
408
+ // Expected on the expand button in the collapsed strip and the collapse
409
+ // button in the expanded header. data-kanban-column-key on the button
410
+ // identifies which column to toggle.
411
+ toggleColumn(event) {
412
+ const key = event.currentTarget.dataset.kanbanColumnKey
413
+ if (!key) return
414
+
415
+ const wrapper = this.element.querySelector(`[data-kanban-col="${key}"]`)
416
+ if (!wrapper) return
417
+
418
+ const strip = wrapper.querySelector("[data-kanban-role='strip']")
419
+ const body = wrapper.querySelector("[data-kanban-role='body']")
420
+ if (!strip || !body) return
421
+
422
+ // `pu-kanban-column-collapsed` on the wrapper is what CSS uses to decide
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)
429
+ }
430
+
431
+ // ─── drag lifecycle ──────────────────────────────────────────────────────────
432
+
433
+ #onDragStart(event) {
434
+ const card = event.target.closest("[data-kanban-record-id]")
435
+ if (!card) return
436
+
437
+ this.draggedCard = card
438
+ event.dataTransfer.effectAllowed = "move"
439
+ // Store the id so native DnD still carries data if the card is dropped
440
+ // outside the board (where we won't handle it, but no error).
441
+ event.dataTransfer.setData("text/plain", card.dataset.kanbanRecordId)
442
+
443
+ // Defer the opacity change so the drag ghost image is captured first.
444
+ requestAnimationFrame(() => card.classList.add("pu-kanban-dragging"))
445
+
446
+ // Mark columns that would reject a drop from this card's source column.
447
+ this.#applyDropHints(card.dataset.kanbanColumnKey)
448
+ }
449
+
450
+ #onDragOver(event) {
451
+ const column = event.target.closest("[data-kanban-target='column']")
452
+ if (!column) return
453
+
454
+ // Skip preventDefault for no-drop columns so the browser shows a
455
+ // "no entry" cursor rather than the move cursor.
456
+ const wrapper = event.target.closest("[data-kanban-col]")
457
+ if (wrapper?.classList.contains("pu-kanban-no-drop")) return
458
+
459
+ event.preventDefault()
460
+ event.dataTransfer.dropEffect = "move"
461
+ this.#highlightColumn(column)
462
+ }
463
+
464
+ #onDragLeave(event) {
465
+ // Only clear the highlight when the cursor leaves the board entirely.
466
+ // relatedTarget is null when leaving the viewport, or a node outside
467
+ // the board wrapper when crossing the edge.
468
+ if (!this.element.contains(event.relatedTarget)) {
469
+ this.#clearHighlights()
470
+ }
471
+ }
472
+
473
+ async #onDrop(event) {
474
+ event.preventDefault()
475
+ this.#clearHighlights()
476
+
477
+ if (!this.draggedCard) return
478
+
479
+ // Respect client-side drop hints: skip the POST for columns the client
480
+ // knows would reject. The server enforces this authoritatively on every
481
+ // request, so skipping saves a round-trip and avoids a 422 flash.
482
+ const wrapper = event.target.closest("[data-kanban-col]")
483
+ if (wrapper?.classList.contains("pu-kanban-no-drop")) return
484
+
485
+ const column = event.target.closest("[data-kanban-target='column']")
486
+ if (!column) return
487
+
488
+ const recordId = this.draggedCard.dataset.kanbanRecordId
489
+ const fromColumn = this.draggedCard.dataset.kanbanColumnKey
490
+ const toColumn = column.dataset.kanbanColumnKeyValue
491
+
492
+ // Cards currently in the destination column, excluding the dragged card
493
+ // itself (it may already be there for a same-column reorder).
494
+ const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
495
+ .filter(c => c !== this.draggedCard)
496
+
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 }) {
531
+ const url = this.moveUrlTemplateValue.replace("__ID__", recordId)
532
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
533
+
534
+ try {
535
+ const response = await fetch(url, {
536
+ method: "POST",
537
+ headers: {
538
+ "Accept": "text/vnd.turbo-stream.html",
539
+ "Content-Type": "application/x-www-form-urlencoded",
540
+ "X-CSRF-Token": csrfToken,
541
+ },
542
+ body: new URLSearchParams({
543
+ from_column: fromColumn,
544
+ to_column: toColumn,
545
+ to_index: toIndex,
546
+ }),
547
+ credentials: "same-origin",
548
+ })
549
+
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.
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")
575
+ }
576
+ } catch (error) {
577
+ console.error("[kanban] move request failed:", error)
578
+ }
579
+ }
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
+
615
+ #onDragEnd(_event) {
616
+ this.#clearHighlights()
617
+ this.#clearDropHints()
618
+ if (this.draggedCard) {
619
+ this.draggedCard.classList.remove("pu-kanban-dragging")
620
+ this.draggedCard = null
621
+ }
622
+ }
623
+
624
+ // ─── drop hints ──────────────────────────────────────────────────────────────
625
+
626
+ // Marks each column wrapper with `pu-kanban-no-drop` when it would refuse
627
+ // a card dragged from sourceKey. The server remains the authority; this
628
+ // is a display-only hint to give the user immediate visual feedback.
629
+ #applyDropHints(sourceKey) {
630
+ // If the source column is locked, no card can leave it — all targets are
631
+ // effectively invalid.
632
+ const sourceWrapper = this.element.querySelector(`[data-kanban-col="${sourceKey}"]`)
633
+ const sourceLocked = sourceWrapper?.dataset.kanbanLocked === "true"
634
+
635
+ this.element.querySelectorAll("[data-kanban-col]").forEach(wrapper => {
636
+ const noDrop = sourceLocked || !this.#columnAccepts(wrapper.dataset.kanbanAccepts, sourceKey)
637
+ wrapper.classList.toggle("pu-kanban-no-drop", noDrop)
638
+ })
639
+ }
640
+
641
+ #clearDropHints() {
642
+ this.element.querySelectorAll("[data-kanban-col]")
643
+ .forEach(w => w.classList.remove("pu-kanban-no-drop"))
644
+ }
645
+
646
+ // Returns true if the column described by `accepts` (the serialised form
647
+ // from data-kanban-accepts) would accept a card from `sourceKey`.
648
+ #columnAccepts(accepts, sourceKey) {
649
+ if (!accepts || accepts === "all") return true
650
+ if (accepts === "none") return false
651
+ return accepts.split(",").map(k => k.trim()).includes(sourceKey)
652
+ }
653
+
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])
669
+ }
670
+
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)
677
+ }
678
+
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`
692
+ }
693
+
694
+ // ─── helpers ─────────────────────────────────────────────────────────────────
695
+
696
+ // Returns the 0-based insertion index within the destination column by
697
+ // comparing the cursor y-position against each card's vertical midpoint.
698
+ // The card is inserted before the first card whose midpoint is below the
699
+ // cursor, or appended after all cards if the cursor is below every midpoint.
700
+ #computeDropIndex(clientY, cards) {
701
+ for (let i = 0; i < cards.length; i++) {
702
+ const rect = cards[i].getBoundingClientRect()
703
+ if (clientY < rect.top + rect.height / 2) return i
704
+ }
705
+ return cards.length
706
+ }
707
+
708
+ #highlightColumn(column) {
709
+ this.columnTargets.forEach(c => {
710
+ c.classList.toggle("pu-kanban-drop-target", c === column)
711
+ })
712
+ }
713
+
714
+ #clearHighlights() {
715
+ this.columnTargets.forEach(c => c.classList.remove("pu-kanban-drop-target"))
716
+ }
717
+ }