plutonium 0.60.5 → 0.61.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 (175) 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 +313 -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 +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  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/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -1,9 +1,17 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="dirty-form-guard"
4
- // Prompts before dismissing a modal form whose contents have changed.
5
- // Self-disables when not inside a <dialog>, so it's safe to attach to
6
- // every form unconditionally.
4
+ // Prompts before discarding a form's unsaved changes. Two guard surfaces,
5
+ // both driven off the same dirtiness diff:
6
+ //
7
+ // • Modal dismissal — when the form is inside a <dialog>, guard Esc / the
8
+ // close button / backdrop cancel (the original behaviour).
9
+ // • Full-page leave — guard a click on any control marked
10
+ // `data-dirty-form-guard-leave="<message>"` that posts WITHOUT this form's
11
+ // fields (e.g. a wizard Back/Cancel). The attribute value is the prompt.
12
+ //
13
+ // Safe to attach to every form unconditionally: with no dialog and no leave
14
+ // controls it never prompts.
7
15
  //
8
16
  // Dirtiness is a diff against a baseline captured at the user's *first*
9
17
  // real interaction — not at connect. Field widgets (intl-tel-input,
@@ -46,52 +54,79 @@ export default class extends Controller {
46
54
 
47
55
  connect() {
48
56
  this.dialog = this.element.closest("dialog");
49
- if (!this.dialog) return;
50
57
 
51
58
  this.baseline = null;
52
59
  this.forceClose = false;
53
60
  this.submitting = false;
54
61
 
55
62
  this.onFirstIntent = this.#onFirstIntent.bind(this);
56
- this.onCancel = this.#onCancel.bind(this);
57
63
  this.onSubmit = this.#onSubmit.bind(this);
58
- this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
59
- this.onConfirmCancel = this.#onConfirmCancel.bind(this);
60
- this.onKeydown = this.#onKeydown.bind(this);
64
+ this.onLeaveClick = this.#onLeaveClick.bind(this);
65
+ this.onSettled = this.#onSettled.bind(this);
61
66
 
62
67
  // A trusted pointer/key action inside the form is the user starting to
63
68
  // edit — capture the (settled, pre-edit) baseline then. Capture phase so
64
- // a widget that stops propagation can't hide it from us.
69
+ // a widget that stops propagation can't hide it from us. Applies in both
70
+ // modal and full-page modes.
65
71
  this.element.addEventListener("pointerdown", this.onFirstIntent, true);
66
72
  this.element.addEventListener("keydown", this.onFirstIntent, true);
73
+ this.element.addEventListener("submit", this.onSubmit);
67
74
 
68
- document.addEventListener("keydown", this.onKeydown, true);
69
- // Capture phase so this runs before remote-modal's cancel handler
70
- // that way `defaultPrevented` is visible there if we intervene.
71
- this.dialog.addEventListener("cancel", this.onCancel, true);
75
+ // When a submission settles, reset the transient guard flags. A failed submit
76
+ // (e.g. a validation error) can re-render the SAME form via Turbo morph, which
77
+ // preserves this element WITHOUT reconnecting the controller so `submitting`
78
+ // / `forceClose` would otherwise stay set and silently kill the guard for the
79
+ // rest of the form's life. Also drop the baseline so the re-rendered form
80
+ // re-baselines against its new values on the next interaction.
81
+ this.element.addEventListener("turbo:submit-end", this.onSettled);
72
82
 
73
- this.element.addEventListener("submit", this.onSubmit);
74
- this.#closeButtons().forEach((btn) =>
75
- btn.addEventListener("click", this.onCloseButtonClick, true),
76
- );
83
+ // Full-page leave guard: a `data-dirty-form-guard-leave` control can live
84
+ // outside this form (a sibling nav strip), so listen at the document in the
85
+ // capture phase to intercept its click before the form it submits. Only for
86
+ // non-modal forms — a modal is guarded by the Esc/close/cancel handlers below,
87
+ // so a modal form needn't install a document-wide listener.
88
+ if (!this.dialog) {
89
+ document.addEventListener("click", this.onLeaveClick, true);
90
+ }
77
91
 
78
- if (this.hasConfirmDialogTarget) {
79
- this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
92
+ if (this.dialog) {
93
+ this.onCancel = this.#onCancel.bind(this);
94
+ this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
95
+ this.onConfirmCancel = this.#onConfirmCancel.bind(this);
96
+ this.onKeydown = this.#onKeydown.bind(this);
97
+
98
+ document.addEventListener("keydown", this.onKeydown, true);
99
+ // Capture phase so this runs before remote-modal's cancel handler
100
+ // — that way `defaultPrevented` is visible there if we intervene.
101
+ this.dialog.addEventListener("cancel", this.onCancel, true);
102
+ this.#closeButtons().forEach((btn) =>
103
+ btn.addEventListener("click", this.onCloseButtonClick, true),
104
+ );
105
+
106
+ if (this.hasConfirmDialogTarget) {
107
+ this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
108
+ }
80
109
  }
81
110
  }
82
111
 
83
112
  disconnect() {
84
- if (!this.dialog) return;
85
113
  this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
86
114
  this.element.removeEventListener("keydown", this.onFirstIntent, true);
87
- document.removeEventListener("keydown", this.onKeydown, true);
88
- this.dialog.removeEventListener("cancel", this.onCancel, true);
89
115
  this.element.removeEventListener("submit", this.onSubmit);
90
- this.#closeButtons().forEach((btn) =>
91
- btn.removeEventListener("click", this.onCloseButtonClick, true),
92
- );
93
- if (this.hasConfirmDialogTarget) {
94
- this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
116
+ this.element.removeEventListener("turbo:submit-end", this.onSettled);
117
+ if (!this.dialog) {
118
+ document.removeEventListener("click", this.onLeaveClick, true);
119
+ }
120
+
121
+ if (this.dialog) {
122
+ document.removeEventListener("keydown", this.onKeydown, true);
123
+ this.dialog.removeEventListener("cancel", this.onCancel, true);
124
+ this.#closeButtons().forEach((btn) =>
125
+ btn.removeEventListener("click", this.onCloseButtonClick, true),
126
+ );
127
+ if (this.hasConfirmDialogTarget) {
128
+ this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
129
+ }
95
130
  }
96
131
  }
97
132
 
@@ -156,6 +191,99 @@ export default class extends Controller {
156
191
  this.submitting = true;
157
192
  }
158
193
 
194
+ // A submission settled. Reset the transient guards so a same-URL Turbo-morph
195
+ // re-render (which keeps this element and does NOT reconnect the controller)
196
+ // doesn't leave the guard permanently disabled. Re-baseline on next interaction.
197
+ #onSettled() {
198
+ this.submitting = false;
199
+ this.forceClose = false;
200
+ this.baseline = null;
201
+ }
202
+
203
+ // Full-page leave guard. A control marked `data-dirty-form-guard-leave` posts
204
+ // without this form's fields, so its unsaved edits would be lost. If the form
205
+ // is dirty, confirm first through the app's themed dialog; the attribute's
206
+ // value is the prompt. We always intercept the click (the themed confirm is
207
+ // async), then re-submit the trigger's form if the user confirms.
208
+ async #onLeaveClick(event) {
209
+ const trigger = event.target.closest("[data-dirty-form-guard-leave]");
210
+ if (!trigger) return;
211
+ // The document listener fires for every guarded form on the page. A leave
212
+ // control discards exactly one form — the one it bypasses — so only that
213
+ // form's instance responds; otherwise unrelated forms would double-prompt.
214
+ if (this.#guardedFormFor(trigger) !== this.element) return;
215
+ if (this.forceClose || this.submitting) return;
216
+ if (!this.#isDirty()) return;
217
+
218
+ event.preventDefault();
219
+ event.stopPropagation();
220
+
221
+ const message =
222
+ trigger.getAttribute("data-dirty-form-guard-leave") ||
223
+ "You have unsaved changes that will be lost. Continue?";
224
+ const confirmed = await this.#confirm(message);
225
+ if (!confirmed) return;
226
+
227
+ // Approved — let the original navigation through without re-prompting. Pass
228
+ // the trigger as the SUBMITTER when it is itself a submit control, so its
229
+ // name/value (e.g. `_direction=back`) is included in the POST — `requestSubmit()`
230
+ // with no submitter drops it, which would silently turn a Back/Cancel into a
231
+ // finalize.
232
+ this.forceClose = true;
233
+ const form = trigger.closest("form");
234
+ if (form) {
235
+ const submitter = trigger.matches("button, input[type=submit], input[type=image]") ? trigger : null;
236
+ form.requestSubmit(submitter);
237
+ }
238
+ }
239
+
240
+ // The CSS selector for a guarded form. The guard is attached as a Stimulus
241
+ // controller (`data-controller="… dirty-form-guard"`), NOT as a CSS class — so
242
+ // match on the controller token, not `form.dirty-form-guard` (which never
243
+ // matches the framework's forms, leaving the leave guard silently dormant).
244
+ static GUARDED_FORM_SELECTOR = "form[data-controller~='dirty-form-guard']";
245
+
246
+ // The single guarded form a leave control discards: the one containing it, or —
247
+ // for a control outside any form (a wizard's sibling nav strip) — the closest
248
+ // guarded form, i.e. the one sharing the deepest common ancestor with the
249
+ // trigger. Returns the only guarded form on simple pages.
250
+ #guardedFormFor(trigger) {
251
+ const selector = this.constructor.GUARDED_FORM_SELECTOR;
252
+ const inside = trigger.closest(selector);
253
+ if (inside) return inside;
254
+
255
+ let best = null;
256
+ let bestDepth = -1;
257
+ document.querySelectorAll(selector).forEach((form) => {
258
+ let ancestor = form;
259
+ while (ancestor && !ancestor.contains(trigger)) ancestor = ancestor.parentElement;
260
+ if (!ancestor) return;
261
+ const depth = this.#depthOf(ancestor);
262
+ if (depth > bestDepth) {
263
+ bestDepth = depth;
264
+ best = form;
265
+ }
266
+ });
267
+ return best;
268
+ }
269
+
270
+ #depthOf(node) {
271
+ let depth = 0;
272
+ while ((node = node.parentElement)) depth++;
273
+ return depth;
274
+ }
275
+
276
+ // Defer to the themed Turbo confirm dialog the app installs as the global
277
+ // confirm method (a styled <dialog>, not the native chrome bar); fall back to
278
+ // window.confirm only if it isn't available. Returns a Promise<boolean>.
279
+ #confirm(message) {
280
+ const turboConfirm = window.Turbo?.config?.forms?.confirm;
281
+ if (typeof turboConfirm === "function") {
282
+ return Promise.resolve(turboConfirm(message));
283
+ }
284
+ return Promise.resolve(window.confirm(message));
285
+ }
286
+
159
287
  #confirmIsOpen() {
160
288
  return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
161
289
  }
@@ -0,0 +1,330 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="kanban"
4
+ //
5
+ // Enables drag-and-drop reordering of kanban cards across columns using the
6
+ // browser's native HTML5 drag-and-drop API (no additional npm dependency).
7
+ //
8
+ // ## DOM contract
9
+ //
10
+ // Board wrapper (this element):
11
+ // data-controller="kanban"
12
+ // data-kanban-move-url-template-value="/path/__ID__/kanban_move"
13
+ // — the collection path with an __ID__ placeholder; the controller
14
+ // substitutes the dragged card's record id at drop time.
15
+ //
16
+ // Column wrapper (rendered by Kanban::Column as the turbo-frame body):
17
+ // data-kanban-col="<key>" — unique wrapper identifier for JS
18
+ // data-kanban-accepts="all|none|<key>,<key>,…"
19
+ // — "all": any card may be dropped here
20
+ // — "none": no card may be dropped here
21
+ // — comma list: only cards whose source column key is in the list
22
+ // data-kanban-locked="true|false"
23
+ // — true: cards in this column cannot be dragged out of it
24
+ //
25
+ // Column drop zone (rendered by Kanban::Column inside each turbo-frame):
26
+ // data-kanban-target="column"
27
+ // data-kanban-column-key-value="<key>"
28
+ //
29
+ // Draggable card (rendered by Kanban::Card):
30
+ // draggable="true"
31
+ // data-kanban-record-id="<id>"
32
+ // data-kanban-column-key="<source-column-key>"
33
+ //
34
+ // Column toggle control (button inside the strip / expanded header):
35
+ // data-action="click->kanban#toggleColumn"
36
+ // data-kanban-column-key="<key>"
37
+ //
38
+ // ## Collapse toggle
39
+ //
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.
45
+ //
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).
49
+ //
50
+ // ## Drop hints
51
+ //
52
+ // On dragstart:
53
+ // 1. Determine whether the source column is locked (cards cannot leave).
54
+ // 2. For each column wrapper, compute whether a drop would be accepted and
55
+ // add the CSS class `pu-kanban-no-drop` to wrappers that would reject.
56
+ // 3. Also suppress the browser's `dragover` preventDefault() for no-drop
57
+ // columns so the native "no entry" cursor shows.
58
+ // On dragend: clear all hint classes.
59
+ //
60
+ // ## Move flow
61
+ //
62
+ // 1. dragstart — record which card was grabbed and apply an opacity hint.
63
+ // 2. dragover — highlight the target column drop zone; suppress the browser's
64
+ // "forbidden" cursor by calling preventDefault(). For columns
65
+ // marked pu-kanban-no-drop we skip preventDefault so the native
66
+ // "no entry" cursor shows instead.
67
+ // 3. drop — compute to_index (vertical cursor position within the column),
68
+ // POST {from_column, to_column, to_index} to the move endpoint,
69
+ // and feed the Turbo Stream response to Turbo.renderStreamMessage.
70
+ // On success the server re-renders both column frames; on 422 it
71
+ // re-renders only the source column so the card snaps back — the
72
+ // controller never hand-manages rollback state.
73
+ // 4. dragend — clean up opacity + highlights + drop-hint classes.
74
+ export default class extends Controller {
75
+ static values = { moveUrlTemplate: String }
76
+ static targets = ["column"]
77
+
78
+ connect() {
79
+ this.draggedCard = null
80
+
81
+ this.onDragStart = this.#onDragStart.bind(this)
82
+ this.onDragOver = this.#onDragOver.bind(this)
83
+ this.onDragLeave = this.#onDragLeave.bind(this)
84
+ this.onDrop = this.#onDrop.bind(this)
85
+ this.onDragEnd = this.#onDragEnd.bind(this)
86
+
87
+ this.element.addEventListener("dragstart", this.onDragStart)
88
+ this.element.addEventListener("dragover", this.onDragOver)
89
+ this.element.addEventListener("dragleave", this.onDragLeave)
90
+ this.element.addEventListener("drop", this.onDrop)
91
+ this.element.addEventListener("dragend", this.onDragEnd)
92
+
93
+ // Apply any persisted collapse states from localStorage so columns
94
+ // retain the user's preference across page reloads.
95
+ this.#applyPersistedCollapseStates()
96
+ }
97
+
98
+ disconnect() {
99
+ this.element.removeEventListener("dragstart", this.onDragStart)
100
+ this.element.removeEventListener("dragover", this.onDragOver)
101
+ this.element.removeEventListener("dragleave", this.onDragLeave)
102
+ this.element.removeEventListener("drop", this.onDrop)
103
+ this.element.removeEventListener("dragend", this.onDragEnd)
104
+ }
105
+
106
+ // ─── Collapse toggle ─────────────────────────────────────────────────────────
107
+
108
+ // Stimulus action: data-action="click->kanban#toggleColumn"
109
+ // Expected on the expand button in the collapsed strip and the collapse
110
+ // button in the expanded header. data-kanban-column-key on the button
111
+ // identifies which column to toggle.
112
+ toggleColumn(event) {
113
+ const key = event.currentTarget.dataset.kanbanColumnKey
114
+ if (!key) return
115
+
116
+ const wrapper = this.element.querySelector(`[data-kanban-col="${key}"]`)
117
+ if (!wrapper) return
118
+
119
+ const strip = wrapper.querySelector("[data-kanban-role='strip']")
120
+ const body = wrapper.querySelector("[data-kanban-role='body']")
121
+ if (!strip || !body) return
122
+
123
+ // `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
+ }
134
+ }
135
+
136
+ // ─── drag lifecycle ──────────────────────────────────────────────────────────
137
+
138
+ #onDragStart(event) {
139
+ const card = event.target.closest("[data-kanban-record-id]")
140
+ if (!card) return
141
+
142
+ this.draggedCard = card
143
+ event.dataTransfer.effectAllowed = "move"
144
+ // Store the id so native DnD still carries data if the card is dropped
145
+ // outside the board (where we won't handle it, but no error).
146
+ event.dataTransfer.setData("text/plain", card.dataset.kanbanRecordId)
147
+
148
+ // Defer the opacity change so the drag ghost image is captured first.
149
+ requestAnimationFrame(() => card.classList.add("pu-kanban-dragging"))
150
+
151
+ // Mark columns that would reject a drop from this card's source column.
152
+ this.#applyDropHints(card.dataset.kanbanColumnKey)
153
+ }
154
+
155
+ #onDragOver(event) {
156
+ const column = event.target.closest("[data-kanban-target='column']")
157
+ if (!column) return
158
+
159
+ // Skip preventDefault for no-drop columns so the browser shows a
160
+ // "no entry" cursor rather than the move cursor.
161
+ const wrapper = event.target.closest("[data-kanban-col]")
162
+ if (wrapper?.classList.contains("pu-kanban-no-drop")) return
163
+
164
+ event.preventDefault()
165
+ event.dataTransfer.dropEffect = "move"
166
+ this.#highlightColumn(column)
167
+ }
168
+
169
+ #onDragLeave(event) {
170
+ // Only clear the highlight when the cursor leaves the board entirely.
171
+ // relatedTarget is null when leaving the viewport, or a node outside
172
+ // the board wrapper when crossing the edge.
173
+ if (!this.element.contains(event.relatedTarget)) {
174
+ this.#clearHighlights()
175
+ }
176
+ }
177
+
178
+ async #onDrop(event) {
179
+ event.preventDefault()
180
+ this.#clearHighlights()
181
+
182
+ if (!this.draggedCard) return
183
+
184
+ // Respect client-side drop hints: skip the POST for columns the client
185
+ // knows would reject. The server enforces this authoritatively on every
186
+ // request, so skipping saves a round-trip and avoids a 422 flash.
187
+ const wrapper = event.target.closest("[data-kanban-col]")
188
+ if (wrapper?.classList.contains("pu-kanban-no-drop")) return
189
+
190
+ const column = event.target.closest("[data-kanban-target='column']")
191
+ if (!column) return
192
+
193
+ const recordId = this.draggedCard.dataset.kanbanRecordId
194
+ const fromColumn = this.draggedCard.dataset.kanbanColumnKey
195
+ const toColumn = column.dataset.kanbanColumnKeyValue
196
+
197
+ // Cards currently in the destination column, excluding the dragged card
198
+ // itself (it may already be there for a same-column reorder).
199
+ const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
200
+ .filter(c => c !== this.draggedCard)
201
+
202
+ const toIndex = this.#computeDropIndex(event.clientY, existingCards)
203
+ const url = this.moveUrlTemplateValue.replace("__ID__", recordId)
204
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
205
+
206
+ try {
207
+ const response = await fetch(url, {
208
+ method: "POST",
209
+ headers: {
210
+ "Accept": "text/vnd.turbo-stream.html",
211
+ "Content-Type": "application/x-www-form-urlencoded",
212
+ "X-CSRF-Token": csrfToken,
213
+ },
214
+ body: new URLSearchParams({
215
+ from_column: fromColumn,
216
+ to_column: toColumn,
217
+ to_index: toIndex,
218
+ }),
219
+ credentials: "same-origin",
220
+ })
221
+
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) {
227
+ Turbo.renderStreamMessage(body)
228
+ }
229
+ } catch (error) {
230
+ console.error("[kanban] move request failed:", error)
231
+ }
232
+ }
233
+
234
+ #onDragEnd(_event) {
235
+ this.#clearHighlights()
236
+ this.#clearDropHints()
237
+ if (this.draggedCard) {
238
+ this.draggedCard.classList.remove("pu-kanban-dragging")
239
+ this.draggedCard = null
240
+ }
241
+ }
242
+
243
+ // ─── drop hints ──────────────────────────────────────────────────────────────
244
+
245
+ // Marks each column wrapper with `pu-kanban-no-drop` when it would refuse
246
+ // a card dragged from sourceKey. The server remains the authority; this
247
+ // is a display-only hint to give the user immediate visual feedback.
248
+ #applyDropHints(sourceKey) {
249
+ // If the source column is locked, no card can leave it — all targets are
250
+ // effectively invalid.
251
+ const sourceWrapper = this.element.querySelector(`[data-kanban-col="${sourceKey}"]`)
252
+ const sourceLocked = sourceWrapper?.dataset.kanbanLocked === "true"
253
+
254
+ this.element.querySelectorAll("[data-kanban-col]").forEach(wrapper => {
255
+ const noDrop = sourceLocked || !this.#columnAccepts(wrapper.dataset.kanbanAccepts, sourceKey)
256
+ wrapper.classList.toggle("pu-kanban-no-drop", noDrop)
257
+ })
258
+ }
259
+
260
+ #clearDropHints() {
261
+ this.element.querySelectorAll("[data-kanban-col]")
262
+ .forEach(w => w.classList.remove("pu-kanban-no-drop"))
263
+ }
264
+
265
+ // Returns true if the column described by `accepts` (the serialised form
266
+ // from data-kanban-accepts) would accept a card from `sourceKey`.
267
+ #columnAccepts(accepts, sourceKey) {
268
+ if (!accepts || accepts === "all") return true
269
+ if (accepts === "none") return false
270
+ return accepts.split(",").map(k => k.trim()).includes(sourceKey)
271
+ }
272
+
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
+ })
287
+ }
288
+
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 */ }
296
+ }
297
+
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`
305
+ }
306
+
307
+ // ─── helpers ─────────────────────────────────────────────────────────────────
308
+
309
+ // Returns the 0-based insertion index within the destination column by
310
+ // comparing the cursor y-position against each card's vertical midpoint.
311
+ // The card is inserted before the first card whose midpoint is below the
312
+ // cursor, or appended after all cards if the cursor is below every midpoint.
313
+ #computeDropIndex(clientY, cards) {
314
+ for (let i = 0; i < cards.length; i++) {
315
+ const rect = cards[i].getBoundingClientRect()
316
+ if (clientY < rect.top + rect.height / 2) return i
317
+ }
318
+ return cards.length
319
+ }
320
+
321
+ #highlightColumn(column) {
322
+ this.columnTargets.forEach(c => {
323
+ c.classList.toggle("pu-kanban-drop-target", c === column)
324
+ })
325
+ }
326
+
327
+ #clearHighlights() {
328
+ this.columnTargets.forEach(c => c.classList.remove("pu-kanban-drop-target"))
329
+ }
330
+ }
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Guards a password field pre-filled with the server "unchanged" sentinel — a
4
+ // stored secret the user hasn't touched, masked behind a fixed placeholder.
5
+ //
6
+ // The sentinel must be edited all-or-nothing: a partial edit (e.g. one
7
+ // backspace) would leave a truncated sentinel that no longer matches on the
8
+ // server and gets saved as a literal new password. So the first edit gesture
9
+ // replaces the whole field — Backspace/Delete empties it (keep-or-clear), a
10
+ // typed/pasted character starts a fresh value. After that first edit the field
11
+ // behaves natively.
12
+ export default class extends Controller {
13
+ static values = { sentinel: String };
14
+
15
+ connect() {
16
+ this.armed = this.element.value === this.sentinelValue;
17
+ }
18
+
19
+ beforeinput(event) {
20
+ if (!this.armed) return;
21
+
22
+ // Replace the whole sentinel regardless of caret position, so a click into
23
+ // the middle followed by a keystroke can't corrupt it.
24
+ event.preventDefault();
25
+ this.armed = false;
26
+
27
+ let next = "";
28
+ if (event.inputType === "insertText" && event.data != null) {
29
+ next = event.data;
30
+ } else if (event.inputType === "insertFromPaste" && event.dataTransfer) {
31
+ next = event.dataTransfer.getData("text");
32
+ }
33
+ // Backspace/Delete (deleteContent*) and anything else collapse to "".
34
+
35
+ this.element.value = next;
36
+ this.element.setSelectionRange(next.length, next.length);
37
+ this.element.dispatchEvent(new Event("input", { bubbles: true }));
38
+ }
39
+ }
@@ -19,6 +19,7 @@ import AttachmentPreviewController from "./attachment_preview_controller.js"
19
19
  import AttachmentPreviewContainerController from "./attachment_preview_container_controller.js"
20
20
  import SidebarController from "./sidebar_controller.js"
21
21
  import PasswordVisibilityController from "./password_visibility_controller.js"
22
+ import PasswordSentinelController from "./password_sentinel_controller.js"
22
23
  import RemoteModalController from "./remote_modal_controller.js"
23
24
  import KeyValueStoreController from "./key_value_store_controller.js"
24
25
  import BulkActionsController from "./bulk_actions_controller.js"
@@ -34,10 +35,13 @@ import RowClickController from "./row_click_controller.js"
34
35
  import ViewSwitcherController from "./view_switcher_controller.js"
35
36
  import AutosubmitController from "./autosubmit_controller.js"
36
37
  import DirtyFormGuardController from "./dirty_form_guard_controller.js"
38
+ import WizardController from "./wizard_controller.js"
39
+ import KanbanController from "./kanban_controller.js"
37
40
 
38
41
  export default function (application) {
39
42
  // Register controllers here
40
43
  application.register("password-visibility", PasswordVisibilityController)
44
+ application.register("password-sentinel", PasswordSentinelController)
41
45
  application.register("sidebar", SidebarController)
42
46
  application.register("resource-header", ResourceHeaderController)
43
47
  application.register("nested-resource-form-fields", NestedResourceFormFieldsController)
@@ -72,4 +76,6 @@ export default function (application) {
72
76
  application.register("view-switcher", ViewSwitcherController)
73
77
  application.register("autosubmit", AutosubmitController)
74
78
  application.register("dirty-form-guard", DirtyFormGuardController)
79
+ application.register("wizard", WizardController)
80
+ application.register("kanban", KanbanController)
75
81
  }
@@ -67,6 +67,16 @@ export default class extends Controller {
67
67
  if (this._closing) return;
68
68
  this._closing = true;
69
69
 
70
+ // Commit any in-flight enter transition to its open end-state before
71
+ // reversing it. Removing data-open while the enter is still running
72
+ // reverses that transition, and CSS shortens the reverse duration in
73
+ // proportion to how far the enter got — so a quick open→close snaps
74
+ // shut instead of animating, which reads as choppy. finish() jumps to
75
+ // the open state so the exit always plays its full duration. Scoped to
76
+ // the dialog's own transitions (not the subtree) so a descendant's
77
+ // infinite animation can't throw on finish().
78
+ this.element.getAnimations().forEach((animation) => animation.finish());
79
+
70
80
  this.element.removeAttribute("data-open");
71
81
 
72
82
  const animations = this.element.getAnimations({ subtree: true });
@@ -16,6 +16,19 @@ export default class extends Controller {
16
16
  if (event.target.closest("a, button, input, label, select, textarea, [data-row-click-ignore]")) {
17
17
  return
18
18
  }
19
- this.element.querySelector('[data-row-click-target="show"]')?.click()
19
+
20
+ const show = this.element.querySelector('[data-row-click-target="show"]')
21
+ if (!show) return
22
+
23
+ // Modifier-click (⌘/Ctrl) or middle-click opens the record's full page in a
24
+ // new tab — the standard "open in new tab" gesture — instead of following
25
+ // the show link's configured target (which may be a modal frame). A new
26
+ // browsing context sends no Turbo-Frame header, so it renders full-page.
27
+ if (event.metaKey || event.ctrlKey || event.button === 1) {
28
+ window.open(show.href, "_blank", "noopener")
29
+ return
30
+ }
31
+
32
+ show.click()
20
33
  }
21
34
  }