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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ # Renders the CURRENT wizard step through the existing resource-form pipeline
7
+ # (§7). It rides `Form::Resource` unchanged — the only wizard-specific wiring
8
+ # is the per-step adapter (`resource_definition`), the value source (the
9
+ # wizard's typed `data`, so inputs render seeded from staged data for
10
+ # resume/back, including repeater rows), the `wizard[...]` param namespace,
11
+ # the step POST URL, and a hidden `_direction` (default `next`).
12
+ class Wizard < Resource
13
+ # @param step [Plutonium::Wizard::Step]
14
+ # @param data [Object] the wizard's typed `data` snapshot (the form
15
+ # `object`; responds to every step attribute / structured input name).
16
+ # @param action [String] the current step's POST URL.
17
+ # @param fields [Array<Symbol>] the step's renderable field names
18
+ # (scalar attributes + structured inputs).
19
+ def initialize(step:, data:, action:, fields:, **options, &)
20
+ @step = step
21
+ options[:key] = :wizard
22
+ options[:as] = :wizard
23
+ options[:action] = action
24
+ options[:resource_fields] = fields
25
+ options[:resource_definition] = Plutonium::Wizard::StepAdapter.new(step)
26
+ options[:singular_resource] = true
27
+ super(data, **options, &)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :step
33
+
34
+ def form_template
35
+ # The direction defaults to "next"; the nav buttons in the page override
36
+ # it per-button. The wizard Stimulus controller targets it.
37
+ input(type: :hidden, name: "_direction", value: "next", data: {wizard_target: "direction"})
38
+ render_fields
39
+ end
40
+
41
+ # The wizard form has no submit footer of its own — the page renders the
42
+ # Back/Next/Finish/Cancel strip. (We still override the resource actions
43
+ # away so no stray "Create"/"Update" button appears.)
44
+ def render_actions
45
+ end
46
+
47
+ # The step form sits INSIDE the wizard card body (which already supplies the
48
+ # surface + padding), so drop the default `pu-card my-4 p-8` form chrome —
49
+ # otherwise it reads as a card-in-card. Keep just the vertical field rhythm.
50
+ def form_class
51
+ "space-y-6"
52
+ end
53
+
54
+ attr_reader :form_action
55
+
56
+ def initialize_attributes
57
+ super
58
+ attributes[:id] = "wizard-form"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -8,18 +8,24 @@ module Plutonium
8
8
  # `grid_fields` on the resource definition. Each slot is optional;
9
9
  # `header` falls back to `record.to_label` when undeclared.
10
10
  class Card < Plutonium::UI::Component::Base
11
- attr_reader :record, :resource_definition, :resource_fields
11
+ attr_reader :record, :resource_definition, :resource_fields, :card_fields
12
12
 
13
- def initialize(record, resource_definition:, resource_fields: nil)
13
+ def initialize(record, resource_definition:, resource_fields: nil, card_fields: nil, show_turbo_frame: nil)
14
14
  @record = record
15
15
  @resource_definition = resource_definition
16
16
  @resource_fields = resource_fields
17
+ @card_fields = card_fields
18
+ # Overrides the show link's turbo-frame target. Defaults to the show
19
+ # action's own frame (nil → normal navigation). The kanban board sets
20
+ # "_top" so a card click escapes its column's lazy turbo-frame instead
21
+ # of loading the show page inside the column.
22
+ @show_turbo_frame = show_turbo_frame
17
23
  end
18
24
 
19
25
  def view_template
20
26
  article(
21
27
  class: card_class,
22
- data: {controller: "row-click", action: "click->row-click#click"}
28
+ data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"}
23
29
  ) do
24
30
  render_show_link if can_show?
25
31
  render_actions_dropdown
@@ -32,7 +38,12 @@ module Plutonium
32
38
 
33
39
  private
34
40
 
35
- def slots = resource_definition.defined_grid_fields
41
+ # Returns the slot hash used for rendering.
42
+ # When the kanban board declares `card_fields`, it is passed in
43
+ # explicitly and takes precedence over the resource definition's
44
+ # `defined_grid_fields`. A nil card_fields falls back to the
45
+ # definition, which is the default for the grid view.
46
+ def slots = @card_fields || resource_definition.defined_grid_fields
36
47
 
37
48
  # ---------------------------------------------------------------
38
49
  # Layout shells
@@ -201,7 +212,7 @@ module Plutonium
201
212
  url = route_options_to_url(show.route_options, record)
202
213
  a(
203
214
  href: url,
204
- data: {row_click_target: "show", turbo_frame: show.turbo_frame(resource_definition)},
215
+ data: {row_click_target: "show", turbo_frame: @show_turbo_frame || show.turbo_frame(resource_definition)},
205
216
  class: "sr-only",
206
217
  tabindex: "-1",
207
218
  "aria-label": "Open #{header_text}"
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Thin wrapper around Grid::Card that makes the card draggable.
7
+ #
8
+ # Emits a draggable container div carrying:
9
+ # data-kanban-record-id — the record's id, consumed by the Stimulus
10
+ # drag controller (Task 11) to identify the card
11
+ # data-kanban-column-key — the source column key, used by the move
12
+ # handler (Task 7) to determine which column
13
+ # the card came from
14
+ #
15
+ # All actual card rendering (image, header, meta, actions dropdown) is
16
+ # delegated to Plutonium::UI::Grid::Card which already handles slots,
17
+ # policy-gated actions, and the row-click controller.
18
+ class Card < Plutonium::UI::Component::Base
19
+ attr_reader :record, :column_key, :resource_definition, :resource_fields, :card_fields
20
+
21
+ def initialize(record, column_key:, resource_definition:, resource_fields:, card_fields: nil, show_turbo_frame: "_top")
22
+ @record = record
23
+ @column_key = column_key
24
+ @resource_definition = resource_definition
25
+ @resource_fields = resource_fields
26
+ # Optional slot-layout override from the board's card_fields declaration.
27
+ # Threaded through to Grid::Card so it takes precedence over the
28
+ # resource definition's grid_fields. nil means use the definition.
29
+ @card_fields = card_fields
30
+ # The turbo-frame the card's show link targets — the remote-modal frame
31
+ # (board show_in :modal) or "_top" (show_in :page). Either escapes the
32
+ # column's lazy frame. Defaults to "_top".
33
+ @show_turbo_frame = show_turbo_frame
34
+ end
35
+
36
+ def view_template
37
+ div(
38
+ draggable: "true",
39
+ data: {
40
+ kanban_record_id: record.id,
41
+ kanban_column_key: column_key.to_s
42
+ }
43
+ ) do
44
+ render_grid_card
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Extracted so tests can stub render_grid_card without needing a full
51
+ # view_context (Grid::Card calls policy_for, route_options_to_url, etc.).
52
+ def render_grid_card
53
+ render Plutonium::UI::Grid::Card.new(
54
+ record,
55
+ resource_definition: resource_definition,
56
+ resource_fields: resource_fields,
57
+ card_fields: @card_fields,
58
+ # Escape the column's lazy turbo-frame: either "_top" (full page) or
59
+ # the document-wide remote-modal frame, both of which resolve outside
60
+ # the kanban-col-<key> frame.
61
+ show_turbo_frame: @show_turbo_frame
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Shared color-dot rendering for the board shell placeholder header
7
+ # (Resource) and the loaded column header (Column), so a column's
8
+ # `color:` shows consistently in both states.
9
+ module ColorDot
10
+ def render_color_dot(color)
11
+ span(
12
+ class: "shrink-0 w-2.5 h-2.5 rounded-full",
13
+ style: "background-color: #{color_css_value(color)}"
14
+ )
15
+ end
16
+
17
+ # Maps a column color symbol to a CSS value via Tailwind design tokens.
18
+ # Raw CSS strings (e.g. "#ff0000") are passed through unchanged.
19
+ def color_css_value(color)
20
+ case color.to_sym
21
+ when :red then "var(--color-red-500)"
22
+ when :orange then "var(--color-orange-500)"
23
+ when :amber then "var(--color-amber-500)"
24
+ when :yellow then "var(--color-yellow-500)"
25
+ when :green then "var(--color-green-500)"
26
+ when :blue then "var(--color-blue-500)"
27
+ when :purple then "var(--color-purple-500)"
28
+ when :pink then "var(--color-pink-500)"
29
+ when :gray then "var(--pu-text-muted)"
30
+ else color.to_s
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Renders a single column body: header with wip badge, card list, and a
7
+ # "+N more" footer when the total exceeds per_column.
8
+ #
9
+ # This is the component that Task 6's lazy frame endpoint serves. The
10
+ # board shell (Resource) wraps each column in a <turbo-frame>; the frame
11
+ # src hits the column endpoint which renders this component as the body.
12
+ #
13
+ # Collapsed variant: when column.collapsed? the component renders a thin
14
+ # rotated-label strip instead of the full card list. Both the strip and
15
+ # the expanded body are always emitted in the HTML; CSS (controlled by
16
+ # the `pu-kanban-column-collapsed` class on the wrapper) shows one and
17
+ # hides the other. The Stimulus controller's toggleColumn action flips the
18
+ # class and persists the choice to localStorage.
19
+ #
20
+ # Action slot: column.actions are rendered as minimal placeholder buttons
21
+ # so the DOM seam is in place for Task 8 which wires the actual action
22
+ # handlers (interactive bulk actions). The buttons carry data-kanban-action
23
+ # and data-kanban-column attributes for Stimulus targeting.
24
+ #
25
+ # Drop policy: the wrapper emits data-kanban-accepts and data-kanban-locked
26
+ # so the Stimulus drag controller can provide client-side drop hints without
27
+ # re-implementing server-side logic. The server remains the authority.
28
+ class Column < Plutonium::UI::Component::Base
29
+ include ColorDot
30
+ include Phlex::Rails::Helpers::LinkTo
31
+
32
+ attr_reader :column, :cards, :total, :per_column, :resource_definition, :resource_fields
33
+
34
+ # column_action_data: array of {action: Plutonium::Kanban::Action, ids: [Integer, ...]}
35
+ # Resolved by the controller (KanbanActions#render_kanban_column_html) and
36
+ # threaded here so the component can render real bulk-action links without
37
+ # needing to re-query the DB. Defaults to [] when the component is
38
+ # constructed outside of a controller context (e.g., tests or the board
39
+ # shell which renders column frames without card data).
40
+ #
41
+ # column_add_url: URL for the "+ Add" quick-add button (or nil).
42
+ # Set by the controller when column.add? is true and the policy permits
43
+ # create. Carries kanban_column=<key> so the new form pre-fills the
44
+ # grouping attribute via apply_kanban_column_defaults!.
45
+ #
46
+ # card_fields: optional slot-layout hash from the board's card_fields
47
+ # declaration (e.g. { header: :title, meta: [:status] }). Threaded
48
+ # through to each Kanban::Card (and ultimately Grid::Card) so it
49
+ # overrides the resource definition's grid_fields for every card in the
50
+ # column. nil means "use the definition's grid_fields" (default).
51
+ # card_show_frame: the turbo-frame each card's show link targets — the
52
+ # remote-modal frame (board show_in :modal) or "_top" (show_in :page).
53
+ # Resolved by the controller and threaded through to Kanban::Card.
54
+ # Defaults to "_top" so a card always escapes the column's lazy frame when
55
+ # the component is built outside a controller (tests, board shell).
56
+ def initialize(column:, cards:, total:, per_column:, resource_definition:, resource_fields:, column_action_data: [], column_add_url: nil, card_fields: nil, card_show_frame: "_top")
57
+ @column = column
58
+ @cards = cards
59
+ @total = total
60
+ @per_column = per_column
61
+ @resource_definition = resource_definition
62
+ @resource_fields = resource_fields
63
+ @column_action_data = column_action_data
64
+ @column_add_url = column_add_url
65
+ @card_fields = card_fields
66
+ @card_show_frame = card_show_frame
67
+ end
68
+
69
+ def view_template
70
+ # Wrapper carries the drop-policy data attributes and the initial
71
+ # collapsed CSS class. The Stimulus controller reads data-kanban-col
72
+ # to find wrappers unambiguously (distinct from toggle button attrs).
73
+ div(
74
+ class: tokens(
75
+ "pu-kanban-column-wrapper",
76
+ column.collapsed? && "pu-kanban-column-collapsed"
77
+ ),
78
+ data: {
79
+ kanban_col: column.key.to_s,
80
+ kanban_accepts: accepts_value,
81
+ kanban_locked: column.locked?.to_s
82
+ }
83
+ ) do
84
+ render_collapsed_strip
85
+ render_expanded
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # ---------------------------------------------------------------
92
+ # Collapsed strip
93
+ # ---------------------------------------------------------------
94
+
95
+ def render_collapsed_strip
96
+ # CSS hides this strip when the wrapper lacks pu-kanban-column-collapsed.
97
+ # Always emitted so the JS toggle can switch between strip and body
98
+ # without a server round-trip.
99
+ div(
100
+ class: "pu-kanban-strip w-10 flex flex-col items-center justify-between " \
101
+ "py-3 gap-2 bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
102
+ "rounded-[var(--pu-radius-md)] select-none",
103
+ data: {kanban_role: "strip"}
104
+ ) do
105
+ span(
106
+ class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
107
+ "[writing-mode:vertical-lr] rotate-180"
108
+ ) { plain column.label }
109
+ span(class: "pu-badge pu-badge-neutral text-xs font-mono") { plain cards.size.to_s }
110
+ # Expand toggle button — the primary interactive seam for
111
+ # collapsing/expanding. data-kanban-column-key is on this button so
112
+ # the integration test can assert the contract without JS execution.
113
+ button(
114
+ class: "p-0.5 rounded text-[var(--pu-text-muted)] " \
115
+ "hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]",
116
+ title: "Expand #{column.label}",
117
+ type: "button",
118
+ data: {
119
+ action: "click->kanban#toggleColumn",
120
+ kanban_column_key: column.key.to_s
121
+ }
122
+ ) { plain "▶" }
123
+ render_column_actions if column.actions.any?
124
+ end
125
+ end
126
+
127
+ # ---------------------------------------------------------------
128
+ # Expanded column
129
+ # ---------------------------------------------------------------
130
+
131
+ def render_expanded
132
+ # CSS hides this body when the wrapper has pu-kanban-column-collapsed.
133
+ div(
134
+ class: "pu-kanban-body pu-kanban-column w-72 shrink-0 flex flex-col " \
135
+ "bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] " \
136
+ "rounded-[var(--pu-radius-md)] overflow-hidden",
137
+ data: {kanban_role: "body"}
138
+ ) do
139
+ render_header
140
+ render_card_list
141
+ render_more_footer if more_count > 0
142
+ end
143
+ end
144
+
145
+ def render_header
146
+ div(class: "px-3 py-2 flex items-center justify-between gap-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
147
+ div(class: "flex items-center gap-2 min-w-0 flex-1") do
148
+ render_color_dot(column.color) if column.color
149
+ span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
150
+ render_wip_badge if column.wip
151
+ end
152
+ render_column_actions if @column_add_url || column.actions.any?
153
+ # Collapse toggle — always present in the expanded header so the
154
+ # user can collapse a column even when no other actions are visible.
155
+ # Kept outside render_column_actions so the action-slot tests are
156
+ # unaffected (they check the flex-gap container which is conditional).
157
+ button(
158
+ class: "shrink-0 p-0.5 rounded text-[var(--pu-text-muted)] " \
159
+ "hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]",
160
+ title: "Collapse #{column.label}",
161
+ type: "button",
162
+ data: {
163
+ action: "click->kanban#toggleColumn",
164
+ kanban_column_key: column.key.to_s
165
+ }
166
+ ) { plain "◀" }
167
+ end
168
+ end
169
+
170
+ def render_wip_badge
171
+ over = wip_over_limit?
172
+ span(
173
+ class: tokens(
174
+ "pu-badge text-xs font-mono",
175
+ over ? "pu-badge-danger" : "pu-badge-neutral"
176
+ ),
177
+ title: over ? "WIP limit exceeded" : "WIP limit: #{column.wip}"
178
+ ) do
179
+ plain "#{cards.size}/#{column.wip}"
180
+ end
181
+ end
182
+
183
+ # ---------------------------------------------------------------
184
+ # Card list
185
+ # ---------------------------------------------------------------
186
+
187
+ def render_card_list
188
+ div(
189
+ class: "flex flex-col gap-2 p-2 min-h-[3rem] flex-1",
190
+ data: {
191
+ kanban_target: "column",
192
+ kanban_column_key_value: column.key.to_s
193
+ }
194
+ ) do
195
+ render_cards
196
+ end
197
+ end
198
+
199
+ # Extracted so tests can stub render_cards without needing a real
200
+ # view_context (Grid::Card requires policy_for etc.).
201
+ def render_cards
202
+ cards.each do |record|
203
+ render Plutonium::UI::Kanban::Card.new(
204
+ record,
205
+ column_key: column.key,
206
+ resource_definition: resource_definition,
207
+ resource_fields: resource_fields,
208
+ card_fields: @card_fields,
209
+ show_turbo_frame: @card_show_frame
210
+ )
211
+ end
212
+ end
213
+
214
+ # ---------------------------------------------------------------
215
+ # "+N more" footer
216
+ # ---------------------------------------------------------------
217
+
218
+ def render_more_footer
219
+ div(class: "px-3 py-2 border-t border-[var(--pu-border)] bg-[var(--pu-surface)]") do
220
+ span(class: "text-xs text-[var(--pu-text-muted)]") do
221
+ plain "+#{more_count} more"
222
+ end
223
+ end
224
+ end
225
+
226
+ # ---------------------------------------------------------------
227
+ # Column action slot
228
+ # ---------------------------------------------------------------
229
+
230
+ # Renders the "+ Add" quick-add button that opens the resource's new form
231
+ # in the remote modal frame, pre-seeded for this column.
232
+ def render_add_button
233
+ link_to(
234
+ @column_add_url,
235
+ class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
236
+ title: "Add to #{column.label}",
237
+ data: {turbo_frame: Plutonium::REMOTE_MODAL_FRAME}
238
+ ) do
239
+ plain "+ Add"
240
+ end
241
+ end
242
+
243
+ # Renders bulk-action links for each column action, and the "+ Add"
244
+ # quick-add button when column_add_url is present.
245
+ #
246
+ # Each bulk-action link targets GET /resources/bulk_actions/:key?ids[]=…,
247
+ # which is the existing interactive_bulk_action route. The action is only
248
+ # rendered when:
249
+ # 1. The resolved action is registered in defined_actions (auto-registered
250
+ # by Definition::IndexViews.kanban at class-load time).
251
+ # 2. current_policy.allowed_to?(:"#{key}?") returns true.
252
+ #
253
+ # The bulk endpoint re-authorizes each record individually, so this
254
+ # check is a display-only gate — not the security boundary.
255
+ def render_column_actions
256
+ div(class: "flex items-center gap-1 shrink-0") do
257
+ render_add_button if @column_add_url
258
+ @column_action_data.each do |entry|
259
+ col_action = entry[:action]
260
+ ids = entry[:ids]
261
+
262
+ registered = current_definition.defined_actions[col_action.key]
263
+ next unless registered&.permitted_by?(current_policy)
264
+
265
+ # Skip when the resolved id set is empty: resource_url_for with
266
+ # ids: [] would resolve to the RESOURCE action route
267
+ # (/resource_actions/:key) rather than the bulk route, misfiring
268
+ # if clicked. An empty column simply renders no action link.
269
+ next if ids.empty?
270
+
271
+ url = resource_url_for(resource_class, interaction: col_action.key, ids: ids)
272
+ label = col_action.label || col_action.key.to_s.humanize
273
+ data_attrs = {
274
+ kanban_action: col_action.key.to_s,
275
+ kanban_column: column.key.to_s
276
+ }
277
+ data_attrs[:turbo_confirm] = col_action.confirmation if col_action.confirmation
278
+
279
+ link_to(
280
+ url,
281
+ class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
282
+ title: label,
283
+ data: data_attrs
284
+ ) do
285
+ render col_action.icon.new(class: "h-4 w-4") if col_action.icon
286
+ plain label
287
+ end
288
+ end
289
+ end
290
+ end
291
+
292
+ # ---------------------------------------------------------------
293
+ # Pure helpers
294
+ # ---------------------------------------------------------------
295
+
296
+ def wip_over_limit?
297
+ column.wip && cards.size > column.wip
298
+ end
299
+
300
+ def more_count
301
+ [total - cards.size, 0].max
302
+ end
303
+
304
+ # Serialises the column's accepts policy for the data-kanban-accepts
305
+ # attribute consumed by the Stimulus drag controller for drop hints.
306
+ # The server remains the authority; this is display only.
307
+ #
308
+ # true → "all" (any source is accepted)
309
+ # false → "none" (no source is accepted)
310
+ # Array → comma-joined list of accepted source column keys
311
+ # Proc → "all" (per-card predicate; treated as permissive at the
312
+ # column level — server evaluates per record)
313
+ def accepts_value
314
+ case column.accepts
315
+ when true then "all"
316
+ when false then "none"
317
+ when Array then column.accepts.join(",")
318
+ else "all"
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end