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,1128 @@
1
+ # Kanban DSL Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a declarative `kanban` board as a first-class index view for Plutonium resources — authored in the resource Definition, rendered as lazy per-column turbo frames, with drag-to-move (a direct action), pluggable decimal positioning, column behaviours/archetypes, column-scoped bulk actions, and opt-in real-time.
6
+
7
+ **Architecture:** The `kanban do…end` DSL compiles to a `Plutonium::Kanban::Board` config object. `:kanban` joins the existing `IndexViews` system, so the board rides the view switcher, `?view=`/cookie resolution, and the index query pipeline (search/filters/scopes). The board consumes the **un-paginated, authorized** relation, groups it into columns, orders each by a decimal `position`, and renders a shell of lazy `<turbo-frame>` columns. A move is a **direct, non-form action** (`kanban_move?` → `update?`); its response re-renders the source+dest frames authoritatively (rollback needs no real-time). Column actions reuse the existing `interactive_bulk_action`. Positioning ships as a standalone `Plutonium::Positioning` model concern.
8
+
9
+ **Tech Stack:** Ruby/Rails (Appraisal: rails-7/8.0/8.1), Phlex view components, Stimulus + SortableJS-style drag, Turbo Streams/Frames, ActionPolicy, TailwindCSS 4, esbuild (`yarn build`/`yarn dev`).
10
+
11
+ **User Verification:** NO — no user feedback/sign-off required by the spec. Verification is automated tests + the maintainer running the dummy app.
12
+
13
+ **Spec:** `docs/superpowers/specs/2026-06-26-kanban-dsl-design.md` (read it before starting; section refs like §5.1 point there).
14
+
15
+ ---
16
+
17
+ ## Conventions for every task
18
+
19
+ - **TDD:** write the failing test first, watch it fail, implement minimally, watch it pass, commit.
20
+ - **Test command:** `bundle exec appraisal rails-8.1 ruby -Itest <file>` for a single file; `bundle exec appraisal rails-8.1 rake test` for the suite. The plain `bundle exec ruby` won't load rodauth — always go through appraisal.
21
+ - **Frontend:** after editing anything in `src/js` or `src/css`, run `yarn build` (writes `app/assets/*`) before integration/system tests; keep `yarn dev` running while iterating.
22
+ - **Commit** at the end of each task with the message shown.
23
+ - **Do NOT** commit unless the task's final step says to (the repo owner's standing rule is "don't commit unless asked" — this plan explicitly asks, per task).
24
+
25
+ ---
26
+
27
+ ## File Structure (created/modified)
28
+
29
+ **New — core (`lib/plutonium/`):**
30
+ - `lib/plutonium/positioning.rb` — standalone decimal-ordering model concern (Task 1)
31
+ - `lib/plutonium/kanban.rb` — namespace requires (Task 2)
32
+ - `lib/plutonium/kanban/dsl.rb` — the `kanban do…end` builder (Task 2)
33
+ - `lib/plutonium/kanban/board.rb` — compiled board config (Task 2)
34
+ - `lib/plutonium/kanban/column.rb` — one column (options, scope, on_drop, actions, behaviours) (Task 2)
35
+ - `lib/plutonium/kanban/action.rb` — compiled column-scoped action (Task 2)
36
+ - `lib/plutonium/kanban/positioning.rb` — Mode A/B/C strategy resolver behind `position_on` (Task 3)
37
+ - `lib/plutonium/kanban/context.rb` — request-bound context for builder/on_drop blocks (Task 4)
38
+ - `lib/plutonium/kanban/grouping.rb` — groups an authorized relation into ordered, capped columns (Task 4)
39
+ - `lib/plutonium/kanban/broadcaster.rb` — opt-in realtime mirror (Task 14)
40
+
41
+ **New — controllers (`lib/plutonium/resource/controllers/`):**
42
+ - `kanban_actions.rb` — move action handler + `kanban_column` frame endpoint + column-action routing (Tasks 6–8)
43
+
44
+ **New — UI (`lib/plutonium/ui/kanban/`):**
45
+ - `resource.rb` — board shell (lazy column frames) (Task 9)
46
+ - `column.rb` — one column's frame body (cards, header, +N more, wip badge) (Task 9, 13)
47
+ - `card.rb` — board card (reuses grid card) (Task 9)
48
+
49
+ **New — assets (`src/`):**
50
+ - `src/js/controllers/kanban_controller.js` — drag/move Stimulus controller (Task 11)
51
+
52
+ **New — tests:** mirrored under `test/plutonium/...` and `test/integration/...` per task.
53
+
54
+ **Modified:**
55
+ - `lib/plutonium/definition/index_views.rb` — add `:kanban` to `KNOWN_VIEWS` + `kanban do…end` DSL entrypoint (Task 0)
56
+ - `lib/plutonium/resource/policy.rb` — `kanban_move?` → `update?` (Task 5)
57
+ - `lib/plutonium/resource/controller.rb` — include `KanbanActions` (Task 7)
58
+ - `lib/plutonium/ui/page/index.rb` — `when :kanban` render branch (Task 10)
59
+ - `lib/plutonium/ui/table/components/view_switcher.rb` — `kanban` segment (Task 10)
60
+ - `lib/plutonium.rb` — require `positioning` + `kanban` (Tasks 1–2)
61
+ - `src/js/controllers/register_controllers.js` — register `kanban` (Task 11)
62
+ - `test/dummy/` — fixtures, a board definition, routes (Task 15)
63
+ - `docs/` + `.claude/skills/` — guide, reference, skill (Tasks 16–17)
64
+
65
+ ---
66
+
67
+ ## Task 0: Register the `:kanban` index view + `kanban do…end` entrypoint
68
+
69
+ **Goal:** A definition can declare `kanban do … end`; doing so adds `:kanban` to the resource's enabled index views (mirroring how `grid_fields` auto-enables `:grid`). No rendering yet — just registration + a stored board builder block.
70
+
71
+ **Files:**
72
+ - Modify: `lib/plutonium/definition/index_views.rb`
73
+ - Test: `test/plutonium/definition/kanban_index_view_test.rb`
74
+
75
+ **Acceptance Criteria:**
76
+ - [ ] `KNOWN_VIEWS` includes `:kanban`.
77
+ - [ ] `kanban { … }` stores the block and appends `:kanban` to `defined_index_views`.
78
+ - [ ] Declaring `kanban` does not remove `:table` (it appends, like `grid_fields`).
79
+ - [ ] `index_views :kanban` still validates `:kanban` as known.
80
+
81
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/kanban_index_view_test.rb` → PASS
82
+
83
+ **Steps:**
84
+
85
+ - [ ] **Step 1: Failing test**
86
+
87
+ ```ruby
88
+ # test/plutonium/definition/kanban_index_view_test.rb
89
+ require "test_helper"
90
+
91
+ class KanbanIndexViewTest < ActiveSupport::TestCase
92
+ def def_class(&blk)
93
+ Class.new(Plutonium::Resource::Definition) do
94
+ class_eval(&blk) if blk
95
+ end
96
+ end
97
+
98
+ test "declaring kanban enables the :kanban view alongside :table" do
99
+ klass = def_class { kanban { } }
100
+ assert_includes klass.defined_index_views, :kanban
101
+ assert_includes klass.defined_index_views, :table
102
+ end
103
+
104
+ test "kanban stores the builder block" do
105
+ klass = def_class { kanban { } }
106
+ assert_kind_of Proc, klass.defined_kanban_block
107
+ end
108
+
109
+ test ":kanban is a known view" do
110
+ assert_includes Plutonium::Definition::IndexViews::KNOWN_VIEWS, :kanban
111
+ end
112
+ end
113
+ ```
114
+
115
+ - [ ] **Step 2: Run → FAIL** (`NoMethodError: undefined method 'kanban'`).
116
+
117
+ - [ ] **Step 3: Implement** in `lib/plutonium/definition/index_views.rb`:
118
+
119
+ ```ruby
120
+ KNOWN_VIEWS = %i[table grid kanban].freeze
121
+ ```
122
+
123
+ Add a class_attribute in the `included do` block:
124
+
125
+ ```ruby
126
+ class_attribute :defined_kanban_block, default: nil, instance_accessor: false
127
+ ```
128
+
129
+ Add the class method (next to `grid_fields`):
130
+
131
+ ```ruby
132
+ # Declares a kanban board for this resource and enables the :kanban
133
+ # index view (mirrors how grid_fields enables :grid). The block is the
134
+ # `kanban do…end` DSL, compiled lazily into a Plutonium::Kanban::Board.
135
+ def kanban(&block)
136
+ self.defined_kanban_block = block
137
+ self.defined_index_views = defined_index_views + [:kanban] unless defined_index_views.include?(:kanban)
138
+ end
139
+ ```
140
+
141
+ Add the instance reader near the others:
142
+
143
+ ```ruby
144
+ def defined_kanban_block = self.class.defined_kanban_block
145
+ ```
146
+
147
+ - [ ] **Step 4: Run → PASS.**
148
+
149
+ - [ ] **Step 5: Commit**
150
+
151
+ ```bash
152
+ git add lib/plutonium/definition/index_views.rb test/plutonium/definition/kanban_index_view_test.rb
153
+ git commit -m "feat(kanban): register :kanban index view + kanban DSL entrypoint"
154
+ ```
155
+
156
+ ```json:metadata
157
+ {"files": ["lib/plutonium/definition/index_views.rb", "test/plutonium/definition/kanban_index_view_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/kanban_index_view_test.rb", "acceptanceCriteria": ["KNOWN_VIEWS includes :kanban", "kanban{} appends :kanban and stores block", "table not removed"], "requiresUserVerification": false}
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Task 1: `Plutonium::Positioning` — standalone decimal ordering concern
163
+
164
+ **Goal:** A model concern providing fractional position: read/order by a decimal column, insert between two neighbors (average; ±1 at ends), column-local rebalance on precision exhaustion, seed on create, and a one-shot backfill. Kanban-independent (§5.1).
165
+
166
+ **Files:**
167
+ - Create: `lib/plutonium/positioning.rb`
168
+ - Modify: `lib/plutonium.rb` (require it)
169
+ - Test: `test/plutonium/positioning_test.rb`
170
+
171
+ **Acceptance Criteria:**
172
+ - [ ] `position_between(prev_val, next_val)` returns the midpoint; `nil` prev → `next - 1`; `nil` next → `prev + 1`; both nil → `0`.
173
+ - [ ] When the gap `(next - prev).abs < EPSILON`, `reposition!` triggers a column-local renumber and still lands the row in order.
174
+ - [ ] Including the concern with `positioned_on :position, scope: :status` sets a `position` on create (append to end of the row's scope group).
175
+ - [ ] `Model.backfill_positions!(order: :created_at)` numbers existing rows per scope group, 1.0, 2.0, ….
176
+
177
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb` → PASS
178
+
179
+ **Steps:**
180
+
181
+ - [ ] **Step 1: Failing test** — exercises the pure math + the AR integration using a throwaway table.
182
+
183
+ ```ruby
184
+ # test/plutonium/positioning_test.rb
185
+ require "test_helper"
186
+
187
+ class PositioningTest < ActiveSupport::TestCase
188
+ # Pure midpoint math (no DB)
189
+ test "position_between midpoint and ends" do
190
+ calc = Plutonium::Positioning
191
+ assert_equal 1.5, calc.position_between(1.0, 2.0)
192
+ assert_equal 1.0, calc.position_between(2.0, nil) # after last -> +1 ... see note
193
+ assert_equal(-1.0, calc.position_between(nil, 0.0)) # before first -> -1
194
+ assert_equal 0.0, calc.position_between(nil, nil)
195
+ end
196
+ end
197
+ ```
198
+
199
+ > Implementation note: define ends as `prev + 1` / `next - 1`. Adjust the test literals to match the exact convention you implement; keep them concrete.
200
+
201
+ - [ ] **Step 2: Run → FAIL.**
202
+
203
+ - [ ] **Step 3: Implement** `lib/plutonium/positioning.rb`:
204
+
205
+ ```ruby
206
+ # frozen_string_literal: true
207
+
208
+ module Plutonium
209
+ # Standalone decimal/fractional ordering. Kanban-independent.
210
+ module Positioning
211
+ extend ActiveSupport::Concern
212
+
213
+ EPSILON = 1e-6
214
+
215
+ # Pure midpoint helpers (module functions, no DB).
216
+ module_function
217
+
218
+ def position_between(prev_val, next_val)
219
+ return 0.0 if prev_val.nil? && next_val.nil?
220
+ return next_val - 1 if prev_val.nil?
221
+ return prev_val + 1 if next_val.nil?
222
+ (prev_val + next_val) / 2.0
223
+ end
224
+
225
+ def gap_exhausted?(prev_val, next_val)
226
+ return false if prev_val.nil? || next_val.nil?
227
+ (next_val - prev_val).abs < EPSILON
228
+ end
229
+
230
+ included do
231
+ class_attribute :positioning_column, instance_accessor: false, default: :position
232
+ class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
233
+ end
234
+
235
+ class_methods do
236
+ # @param column [Symbol] decimal attribute holding order
237
+ # @param scope [Symbol, nil] attribute that partitions ordering (e.g. :status)
238
+ def positioned_on(column = :position, scope: nil)
239
+ self.positioning_column = column
240
+ self.positioning_scope_attr = scope
241
+ before_create :assign_initial_position
242
+ end
243
+
244
+ # One-shot: number existing rows per scope group by `order`.
245
+ def backfill_positions!(order: :created_at)
246
+ groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
247
+ groups.each_value do |rows|
248
+ rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
249
+ row.update_column(positioning_column, (i + 1).to_f)
250
+ end
251
+ end
252
+ end
253
+ end
254
+
255
+ # Place this record between two neighbor records (either may be nil) and persist.
256
+ def reposition!(prev_record:, next_record:)
257
+ col = self.class.positioning_column
258
+ prev_val = prev_record&.public_send(col)
259
+ next_val = next_record&.public_send(col)
260
+ if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
261
+ rebalance_scope_group!
262
+ prev_val = prev_record&.reload&.public_send(col)
263
+ next_val = next_record&.reload&.public_send(col)
264
+ end
265
+ update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
266
+ end
267
+
268
+ private
269
+
270
+ def assign_initial_position
271
+ col = self.class.positioning_column
272
+ return if public_send(col).present?
273
+ max = positioning_group_relation.maximum(col) || 0.0
274
+ public_send("#{col}=", max + 1)
275
+ end
276
+
277
+ def positioning_group_relation
278
+ rel = self.class.all
279
+ attr = self.class.positioning_scope_attr
280
+ attr ? rel.where(attr => public_send(attr)) : rel
281
+ end
282
+
283
+ def rebalance_scope_group!
284
+ col = self.class.positioning_column
285
+ positioning_group_relation.order(col).each_with_index do |row, i|
286
+ row.update_column(col, (i + 1).to_f)
287
+ end
288
+ end
289
+ end
290
+ end
291
+ ```
292
+
293
+ Add to `lib/plutonium.rb` (with the other `require`s):
294
+
295
+ ```ruby
296
+ require "plutonium/positioning"
297
+ ```
298
+
299
+ - [ ] **Step 4:** Add DB-backed tests (throwaway table via a migration in the test, or reuse an existing dummy model with a `position` column — prefer the dummy `Task` fixture introduced in Task 15 if ordering of tasks allows; otherwise create an ad-hoc table in setup). Cover: create assigns position; reposition between two rows; rebalance on exhausted gap; backfill. Run → PASS.
300
+
301
+ - [ ] **Step 5: Commit**
302
+
303
+ ```bash
304
+ git add lib/plutonium/positioning.rb lib/plutonium.rb test/plutonium/positioning_test.rb
305
+ git commit -m "feat(positioning): standalone decimal ordering concern"
306
+ ```
307
+
308
+ ```json:metadata
309
+ {"files": ["lib/plutonium/positioning.rb", "lib/plutonium.rb", "test/plutonium/positioning_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb", "acceptanceCriteria": ["midpoint + ends math", "rebalance on exhausted gap", "seed on create", "backfill_positions!"], "requiresUserVerification": false}
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Task 2: Kanban DSL → Board / Column / Action compilation
315
+
316
+ **Goal:** The `kanban do…end` builder compiles to immutable config: a `Board` (ordered columns, card config, per_column, realtime, positioning config, lazy flag), `Column`s (key/label/color/wip/scope/on_drop/behaviours/actions), and `Action`s (key/interaction/on/label/icon/confirmation). Pure data — no request, no DB.
317
+
318
+ **Files:**
319
+ - Create: `lib/plutonium/kanban.rb`, `lib/plutonium/kanban/dsl.rb`, `lib/plutonium/kanban/board.rb`, `lib/plutonium/kanban/column.rb`, `lib/plutonium/kanban/action.rb`
320
+ - Modify: `lib/plutonium.rb`
321
+ - Test: `test/plutonium/kanban/dsl_test.rb`
322
+
323
+ **Acceptance Criteria:**
324
+ - [ ] `Plutonium::Kanban::DSL.build(&block)` returns a `Board`.
325
+ - [ ] `column :k, label:, color:, wip:, scope:, on_drop:` + behaviour opts (`collapsed:`, `add:`, `accepts:`, `locked:`, `role:`) compile to a `Column`; columns keep declaration order.
326
+ - [ ] A column block declaring `action :k, interaction:, on:` compiles to an `Action` on that column.
327
+ - [ ] `role: :backlog` ⇒ `add: true`; `role: :done` ⇒ `color: :green, collapsed: true` (explicit opts override the role).
328
+ - [ ] `scope:`/`on_drop:` accept a Proc **or** a Symbol; stored verbatim (resolution happens at request time).
329
+ - [ ] `card_fields(**slots)`, `per_column n`, `realtime true`, `position_on …` stored on the Board.
330
+ - [ ] `columns do … end` stores a dynamic builder block (mutually exclusive with static `column`s at render time — validated in Task 4).
331
+
332
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/dsl_test.rb` → PASS
333
+
334
+ **Steps:**
335
+
336
+ - [ ] **Step 1: Failing test**
337
+
338
+ ```ruby
339
+ # test/plutonium/kanban/dsl_test.rb
340
+ require "test_helper"
341
+
342
+ class KanbanDslTest < ActiveSupport::TestCase
343
+ def build(&blk) = Plutonium::Kanban::DSL.build(&blk)
344
+
345
+ test "static columns compile in order with options" do
346
+ board = build do
347
+ column :todo, label: "To Do", scope: -> { where(status: :todo) }, on_drop: ->(t) { t.status = :todo }
348
+ column :doing, label: "Doing", wip: 3, scope: :in_progress, on_drop: :start!
349
+ end
350
+ assert_equal %i[todo doing], board.columns.map(&:key)
351
+ assert_equal 3, board.columns[1].wip
352
+ assert_equal :in_progress, board.columns[1].scope # symbol stored verbatim
353
+ end
354
+
355
+ test "role presets apply but are overridable" do
356
+ board = build do
357
+ column :backlog, role: :backlog, scope: -> {}, on_drop: ->(_) {}
358
+ column :done, role: :done, collapsed: false, scope: -> {}, on_drop: ->(_) {}
359
+ end
360
+ assert board.columns[0].add? # from role
361
+ assert_equal :green, board.columns[1].color
362
+ refute board.columns[1].collapsed? # explicit override wins
363
+ end
364
+
365
+ test "column action compiles" do
366
+ board = build do
367
+ column :done, scope: -> {}, on_drop: ->(_) {} do
368
+ action :archive, interaction: :archive_int, on: :all, label: "Archive"
369
+ end
370
+ end
371
+ act = board.columns[0].actions.first
372
+ assert_equal :archive, act.key
373
+ assert_equal :all, act.on
374
+ end
375
+
376
+ test "board-level config" do
377
+ board = build do
378
+ per_column 25
379
+ realtime true
380
+ card_fields(header: :name)
381
+ end
382
+ assert_equal 25, board.per_column
383
+ assert board.realtime?
384
+ assert_equal({header: :name}, board.card_fields)
385
+ end
386
+ end
387
+ ```
388
+
389
+ - [ ] **Step 2: Run → FAIL.**
390
+
391
+ - [ ] **Step 3: Implement.** `Action` (Struct-like), `Column` (with `role` expansion + behaviour predicates + an `action` collector used when its block runs), `Board`, and `DSL` (an instance_eval target collecting columns + board config). Key code:
392
+
393
+ `lib/plutonium/kanban/action.rb`:
394
+
395
+ ```ruby
396
+ # frozen_string_literal: true
397
+ module Plutonium
398
+ module Kanban
399
+ Action = Data.define(:key, :interaction, :on, :label, :icon, :confirmation) do
400
+ def initialize(key:, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
401
+ super
402
+ end
403
+ end
404
+ end
405
+ end
406
+ ```
407
+
408
+ `lib/plutonium/kanban/column.rb`:
409
+
410
+ ```ruby
411
+ # frozen_string_literal: true
412
+ module Plutonium
413
+ module Kanban
414
+ class Column
415
+ ROLE_PRESETS = {
416
+ backlog: {add: true},
417
+ done: {color: :green, collapsed: true}
418
+ }.freeze
419
+
420
+ attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions
421
+
422
+ def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
423
+ collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil)
424
+ preset = role ? ROLE_PRESETS.fetch(role, {}) : {}
425
+ @key = key.to_sym
426
+ @label = label || key.to_s.titleize
427
+ @color = color.nil? ? preset[:color] : color
428
+ @wip = wip
429
+ @scope = scope
430
+ @on_drop = on_drop
431
+ @collapsed = collapsed.nil? ? preset[:collapsed] : collapsed
432
+ @add = add.nil? ? preset[:add] : add
433
+ @accepts = accepts.nil? ? true : accepts
434
+ @locked = locked || false
435
+ @actions = []
436
+ end
437
+
438
+ # Collected when the column's block runs (see DSL#column).
439
+ def action(key, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
440
+ @actions << Action.new(key: key.to_sym, interaction:, on:, label:, icon:, confirmation:)
441
+ end
442
+
443
+ def collapsed? = !!@collapsed
444
+ def add? = !!@add
445
+ def locked? = @locked
446
+
447
+ # Does this column accept a card dragged from `source_key`?
448
+ # (used by the move action, Task 7). Proc form is evaluated per-card
449
+ # at move time, so here it permits and the handler applies the predicate.
450
+ def accepts?(source_key)
451
+ case @accepts
452
+ when Array then @accepts.include?(source_key)
453
+ when true, false then @accepts
454
+ else true
455
+ end
456
+ end
457
+ end
458
+ end
459
+ end
460
+ ```
461
+
462
+ `lib/plutonium/kanban/board.rb`:
463
+
464
+ ```ruby
465
+ # frozen_string_literal: true
466
+ module Plutonium
467
+ module Kanban
468
+ class Board
469
+ attr_reader :columns, :columns_block, :card_fields, :per_column,
470
+ :position_config, :lazy
471
+
472
+ def initialize(columns:, columns_block:, card_fields:, per_column:, realtime:, position_config:, lazy:)
473
+ @columns = columns
474
+ @columns_block = columns_block
475
+ @card_fields = card_fields
476
+ @per_column = per_column
477
+ @realtime = realtime
478
+ @position_config = position_config # see Task 3
479
+ @lazy = lazy
480
+ freeze
481
+ end
482
+
483
+ def realtime? = !!@realtime
484
+ def dynamic? = !@columns_block.nil?
485
+ end
486
+ end
487
+ end
488
+ ```
489
+
490
+ `lib/plutonium/kanban/dsl.rb`:
491
+
492
+ ```ruby
493
+ # frozen_string_literal: true
494
+ module Plutonium
495
+ module Kanban
496
+ class DSL
497
+ def self.build(&block)
498
+ dsl = new
499
+ dsl.instance_eval(&block) if block
500
+ dsl.to_board
501
+ end
502
+
503
+ def initialize
504
+ @columns = []
505
+ @columns_block = nil
506
+ @card_fields = nil
507
+ @per_column = nil
508
+ @realtime = false
509
+ @position_config = Positioning::Config.default # Task 3
510
+ @lazy = true
511
+ end
512
+
513
+ def column(key, **opts, &blk)
514
+ col = Column.new(key, **opts)
515
+ col.instance_eval(&blk) if blk # collects `action ...`
516
+ @columns << col
517
+ end
518
+
519
+ def columns(&blk) = @columns_block = blk
520
+ def card_fields(**slots) = @card_fields = slots
521
+ def per_column(n) = @per_column = n
522
+ def realtime(v = true) = @realtime = v
523
+ def lazy(v = true) = @lazy = v
524
+ def position_on(attr = :position, &blk) = @position_config = Positioning::Config.new(attr, false, blk)
525
+ # `position_on false` disables:
526
+ def disable_positioning! = @position_config = Positioning::Config.disabled
527
+
528
+ def to_board
529
+ Board.new(columns: @columns, columns_block: @columns_block, card_fields: @card_fields,
530
+ per_column: @per_column, realtime: @realtime, position_config: @position_config, lazy: @lazy)
531
+ end
532
+ end
533
+ end
534
+ end
535
+ ```
536
+
537
+ > Handle `position_on false`: in `position_on`, if the first arg is `false`, set disabled config. Keep the signature `position_on(attr = :position, &blk)` and branch on `attr == false`.
538
+
539
+ `lib/plutonium/kanban.rb`:
540
+
541
+ ```ruby
542
+ # frozen_string_literal: true
543
+ require "plutonium/kanban/positioning"
544
+ require "plutonium/kanban/action"
545
+ require "plutonium/kanban/column"
546
+ require "plutonium/kanban/board"
547
+ require "plutonium/kanban/dsl"
548
+ ```
549
+
550
+ Add `require "plutonium/kanban"` to `lib/plutonium.rb` (after positioning).
551
+
552
+ - [ ] **Step 4: Run → PASS.**
553
+
554
+ - [ ] **Step 5: Commit**
555
+
556
+ ```bash
557
+ git add lib/plutonium/kanban.rb lib/plutonium/kanban/ lib/plutonium.rb test/plutonium/kanban/dsl_test.rb
558
+ git commit -m "feat(kanban): compile kanban DSL into Board/Column/Action config"
559
+ ```
560
+
561
+ ```json:metadata
562
+ {"files": ["lib/plutonium/kanban/dsl.rb", "lib/plutonium/kanban/board.rb", "lib/plutonium/kanban/column.rb", "lib/plutonium/kanban/action.rb", "lib/plutonium/kanban.rb", "lib/plutonium.rb", "test/plutonium/kanban/dsl_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/dsl_test.rb", "acceptanceCriteria": ["columns compile in order", "role presets overridable", "actions compile", "board config stored", "scope/on_drop accept proc or symbol"], "requiresUserVerification": false}
563
+ ```
564
+
565
+ ---
566
+
567
+ ## Task 3: Positioning strategy resolver (`position_on` Mode A/B/C)
568
+
569
+ **Goal:** Resolve the board's `position_config` to a strategy: Mode A (delegate to `Plutonium::Positioning`), Mode B (author block does the write), Mode C (disabled). Provide `Config` (used in Task 2) and a `Strategy#reposition!(record, prev:, next:, index:, column:)`.
570
+
571
+ **Files:**
572
+ - Create: `lib/plutonium/kanban/positioning.rb`
573
+ - Test: `test/plutonium/kanban/positioning_test.rb`
574
+
575
+ **Acceptance Criteria:**
576
+ - [ ] `Config.default` → Mode A on `:position`; `Config.new(:rank, …)` → Mode A on `:rank`; with a block → Mode B; `Config.disabled` → Mode C.
577
+ - [ ] Mode A `reposition!` calls the record's `Plutonium::Positioning#reposition!` with neighbor records.
578
+ - [ ] Mode B `reposition!` calls the author block with a `move` carrying `record, column, prev, next, index`.
579
+ - [ ] Mode C `reposition!` is a no-op (and the board orders by column scope, asserted in Task 4).
580
+
581
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb` → PASS
582
+
583
+ **Steps:**
584
+
585
+ - [ ] **Step 1: Failing test** with a fake record capturing calls (Mode A/B) and a no-op assertion (Mode C). Use a `Struct` double exposing `reposition!`.
586
+ - [ ] **Step 2: Run → FAIL.**
587
+ - [ ] **Step 3: Implement** `Plutonium::Kanban::Positioning` with `Config` (Data) + `Strategy` resolving on mode. `move` is a `Data.define(:record, :column, :prev, :next, :index)`.
588
+ - [ ] **Step 4: Run → PASS.**
589
+ - [ ] **Step 5: Commit**
590
+
591
+ ```bash
592
+ git add lib/plutonium/kanban/positioning.rb test/plutonium/kanban/positioning_test.rb
593
+ git commit -m "feat(kanban): position_on strategy resolver (Mode A/B/C)"
594
+ ```
595
+
596
+ ```json:metadata
597
+ {"files": ["lib/plutonium/kanban/positioning.rb", "test/plutonium/kanban/positioning_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb", "acceptanceCriteria": ["Config default/attr/block/disabled", "Mode A delegates", "Mode B yields move", "Mode C no-op"], "requiresUserVerification": false}
598
+ ```
599
+
600
+ ---
601
+
602
+ ## Task 4: Request binding — `Context` + `Grouping`
603
+
604
+ **Goal:** Bind a `Board` to a request: build the column set (static, or via `columns do…end` in a `Context` exposing `current_user`/`current_scoped_entity`/`params`/helpers), then group the **authorized, un-paginated** relation into ordered, `per_column`-capped buckets — ordering each column by the positioning attribute (overriding any `default_sort`).
605
+
606
+ **Files:**
607
+ - Create: `lib/plutonium/kanban/context.rb`, `lib/plutonium/kanban/grouping.rb`
608
+ - Test: `test/plutonium/kanban/grouping_test.rb`
609
+
610
+ **Acceptance Criteria:**
611
+ - [ ] `Context` is a `SimpleDelegator` over `view_context` exposing `current_user`, `current_scoped_entity`, `params` (mirrors `Plutonium::Action::ConditionContext`).
612
+ - [ ] `Grouping.call(board:, relation:, context:)` returns ordered `[{column:, cards:, total:}]`.
613
+ - [ ] A column's `scope:` Proc is evaluated **against the relation** (`relation.instance_exec(&scope)`); a Symbol calls `relation.public_send(sym)`.
614
+ - [ ] Cards are ordered by the positioning attr (Mode A/B) — overriding `default_sort`; Mode C uses the column scope's own order.
615
+ - [ ] `per_column` caps `cards` and reports `total` (for "+N more").
616
+ - [ ] Dynamic boards (`columns do…end`) build columns from the context.
617
+
618
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/grouping_test.rb` → PASS
619
+
620
+ **Steps:**
621
+
622
+ - [ ] **Step 1: Failing test** using a dummy model (use the `Task` fixture from Task 15 if available; otherwise an ad-hoc table). Assert ordering-by-position overrides a `default_sort`, scope Proc vs Symbol both work, and `per_column` caps with correct `total`.
623
+ - [ ] **Step 2: Run → FAIL.**
624
+ - [ ] **Step 3: Implement.** `Context` mirrors `ConditionContext` (`SimpleDelegator.new(view_context)`). `Grouping`:
625
+
626
+ ```ruby
627
+ # frozen_string_literal: true
628
+ module Plutonium
629
+ module Kanban
630
+ module Grouping
631
+ module_function
632
+
633
+ def call(board:, relation:, context:)
634
+ columns = resolve_columns(board, context)
635
+ pos = board.position_config
636
+ columns.map do |col|
637
+ scoped = apply_scope(relation, col.scope, context)
638
+ ordered = pos.order(scoped) # by position attr, or scope order in Mode C
639
+ total = ordered.count
640
+ cards = board.per_column ? ordered.limit(board.per_column).to_a : ordered.to_a
641
+ {column: col, cards:, total:}
642
+ end
643
+ end
644
+
645
+ def resolve_columns(board, context)
646
+ return board.columns unless board.dynamic?
647
+ Array(context.instance_exec(&board.columns_block)).flatten
648
+ end
649
+
650
+ def apply_scope(relation, scope, context)
651
+ case scope
652
+ when Symbol then relation.public_send(scope)
653
+ when Proc then relation.instance_exec(&scope)
654
+ when nil then relation
655
+ else relation.merge(scope)
656
+ end
657
+ end
658
+ end
659
+ end
660
+ end
661
+ ```
662
+
663
+ > `pos.order(scoped)` lives on the positioning `Strategy` (Task 3): Mode A/B → `scoped.reorder(attr)`; Mode C → `scoped` unchanged. `reorder` (not `order`) is what overrides `default_sort`.
664
+
665
+ - [ ] **Step 4: Run → PASS.**
666
+ - [ ] **Step 5: Commit**
667
+
668
+ ```bash
669
+ git add lib/plutonium/kanban/context.rb lib/plutonium/kanban/grouping.rb test/plutonium/kanban/grouping_test.rb
670
+ git commit -m "feat(kanban): request context + relation grouping into ordered columns"
671
+ ```
672
+
673
+ ```json:metadata
674
+ {"files": ["lib/plutonium/kanban/context.rb", "lib/plutonium/kanban/grouping.rb", "test/plutonium/kanban/grouping_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/grouping_test.rb", "acceptanceCriteria": ["context exposes current_user/entity", "scope proc+symbol", "order overrides default_sort", "per_column caps + total", "dynamic columns"], "requiresUserVerification": false}
675
+ ```
676
+
677
+ ---
678
+
679
+ ## Task 5: Policy hook `kanban_move?` → `update?`
680
+
681
+ **Goal:** Add a single delegating policy predicate so a move authorizes like an update, and the board is read-only when it returns false.
682
+
683
+ **Files:**
684
+ - Modify: `lib/plutonium/resource/policy.rb`
685
+ - Test: `test/plutonium/resource/kanban_policy_test.rb`
686
+
687
+ **Acceptance Criteria:**
688
+ - [ ] `kanban_move?` returns the same as `update?` by default.
689
+ - [ ] Overriding `update?` to false makes `kanban_move?` false; a subclass can override `kanban_move?` independently.
690
+
691
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/kanban_policy_test.rb` → PASS
692
+
693
+ **Steps:**
694
+ - [ ] **Step 1: Failing test** (two policy subclasses: one default, one overriding `update?` false; one overriding `kanban_move?` true while `update?` false).
695
+ - [ ] **Step 2: Run → FAIL.**
696
+ - [ ] **Step 3: Implement** near `update?` in `policy.rb`:
697
+
698
+ ```ruby
699
+ # Authorizes a kanban move. Delegates to update? by default — override to
700
+ # allow board drags without granting full edit-form access.
701
+ def kanban_move? = update?
702
+ ```
703
+
704
+ - [ ] **Step 4: Run → PASS.**
705
+ - [ ] **Step 5: Commit**
706
+
707
+ ```bash
708
+ git add lib/plutonium/resource/policy.rb test/plutonium/resource/kanban_policy_test.rb
709
+ git commit -m "feat(kanban): kanban_move? policy predicate delegating to update?"
710
+ ```
711
+
712
+ ```json:metadata
713
+ {"files": ["lib/plutonium/resource/policy.rb", "test/plutonium/resource/kanban_policy_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/kanban_policy_test.rb", "acceptanceCriteria": ["kanban_move? defaults to update?", "independently overridable"], "requiresUserVerification": false}
714
+ ```
715
+
716
+ ---
717
+
718
+ ## Task 6: `kanban_column` frame endpoint
719
+
720
+ **Goal:** A lightweight controller action rendering ONE column's cards (the frame `src`): resolve the board, build the authorized + query-applied relation (reuse the existing index query pipeline), group, and render just the requested column's body.
721
+
722
+ **Files:**
723
+ - Create: `lib/plutonium/resource/controllers/kanban_actions.rb` (start it here; extended in Tasks 7–8)
724
+ - Modify: `lib/plutonium/resource/controller.rb` (include concern)
725
+ - Test: `test/integration/admin_portal/kanban_column_test.rb`
726
+
727
+ **Acceptance Criteria:**
728
+ - [ ] `GET …?view=kanban&column=<key>` renders that column's cards (turbo-frame body), ordered by position, capped at `per_column`.
729
+ - [ ] The relation is the **authorized + query-applied** scope (search/filters/scopes honored), **not paginated**.
730
+ - [ ] Unknown `column` → 404/empty frame (no crash).
731
+
732
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_test.rb` → PASS (after Task 15 fixtures exist; if running earlier, stub a board in the dummy).
733
+
734
+ > **Dependency note:** integration tests here rely on the dummy `Task` board from Task 15. If executing strictly in order, write the endpoint + a controller unit test now and add the integration assertions when Task 15 lands. Prefer reordering Task 15 earlier if convenient.
735
+
736
+ **Steps:**
737
+ - [ ] **Step 1: Failing test.**
738
+ - [ ] **Step 2: Run → FAIL.**
739
+ - [ ] **Step 3: Implement** the concern with a `current_kanban_board` memo (compiles `current_definition.defined_kanban_block` via `Kanban::DSL.build`), a `kanban_base_relation` (reuse `current_query_object.apply(authorized_scope(resource_class.all), params)` minus pagination — see `Queryable`), and `kanban_column` rendering `Plutonium::UI::Kanban::Column` (Task 9) for the one column.
740
+ - [ ] **Step 4: Run → PASS.**
741
+ - [ ] **Step 5: Commit**
742
+
743
+ ```bash
744
+ git add lib/plutonium/resource/controllers/kanban_actions.rb lib/plutonium/resource/controller.rb test/integration/admin_portal/kanban_column_test.rb
745
+ git commit -m "feat(kanban): lazy per-column frame endpoint"
746
+ ```
747
+
748
+ ```json:metadata
749
+ {"files": ["lib/plutonium/resource/controllers/kanban_actions.rb", "lib/plutonium/resource/controller.rb", "test/integration/admin_portal/kanban_column_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_test.rb", "acceptanceCriteria": ["renders one column's cards", "authorized+query-applied unpaginated relation", "unknown column safe"], "requiresUserVerification": false}
750
+ ```
751
+
752
+ ---
753
+
754
+ ## Task 7: The move action (direct, non-form)
755
+
756
+ **Goal:** Register and handle the move: authorize `kanban_move?`, enforce `accepts:`/`locked:`, apply `on_drop`, compute fractional position, enforce `wip`, save in a transaction, and respond with frame-scoped Turbo Streams re-rendering source + destination columns (snap-back on failure).
757
+
758
+ **Files:**
759
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb`, the resource routing (member route for the move)
760
+ - Test: `test/integration/admin_portal/kanban_move_test.rb`
761
+
762
+ **Acceptance Criteria:**
763
+ - [ ] `POST …/<id>/kanban_move` with `{from_column,to_column,to_index}` moves the card: `on_drop` applied, `position` set between neighbors at `to_index`.
764
+ - [ ] `kanban_move?` false → 403, no mutation.
765
+ - [ ] Destination `accepts:` excluding `from_column` → 422, no mutation, response re-renders the **unchanged** source frame.
766
+ - [ ] `wip` exceeded → 422, no mutation.
767
+ - [ ] Success → Turbo Stream replacing `kanban-col-<from>` and `kanban-col-<to>` frames.
768
+ - [ ] `on_drop` symbol form (`record.public_send(:sym)`) works.
769
+
770
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_move_test.rb` → PASS
771
+
772
+ **Steps:**
773
+ - [ ] **Step 1: Failing test** covering all six criteria.
774
+ - [ ] **Step 2: Run → FAIL.**
775
+ - [ ] **Step 3: Implement** `kanban_move`:
776
+
777
+ ```ruby
778
+ def kanban_move
779
+ board = current_kanban_board
780
+ record = authorized_resource_scope.find(params[:id])
781
+ authorize! record, to: :kanban_move?
782
+
783
+ # Resolve columns via the context so dynamic (columns do…end) boards work too.
784
+ cols = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
785
+ from = cols.find { |c| c.key == params[:from_column].to_sym }
786
+ to = cols.find { |c| c.key == params[:to_column].to_sym }
787
+ raise Plutonium::Kanban::DropRejected unless to.accepts?(from.key) && !from.locked?
788
+
789
+ resource_record_transaction do
790
+ apply_on_drop(to, record) # Proc -> instance_exec(record); Symbol -> record.public_send
791
+ reposition(board, to, record, params[:to_index].to_i)
792
+ enforce_wip!(to, record)
793
+ record.save!
794
+ end
795
+
796
+ render_kanban_frames(from, to)
797
+ rescue Plutonium::Kanban::DropRejected, ActiveRecord::RecordInvalid
798
+ render_kanban_frames(from, to, status: :unprocessable_content) # unchanged source snaps card back
799
+ end
800
+ ```
801
+
802
+ Wire the route: register `kanban_move` as a member action (extend the resource route registration the way `wizard_registration`/`mapper_extensions` add custom member routes; or add `member { post :kanban_move }` in the resource route helper). Mark it a direct action excluded from rendered toolbars.
803
+
804
+ - [ ] **Step 4: Run → PASS.**
805
+ - [ ] **Step 5: Commit**
806
+
807
+ ```bash
808
+ git add lib/plutonium/resource/controllers/kanban_actions.rb test/integration/admin_portal/kanban_move_test.rb
809
+ git commit -m "feat(kanban): move action — drop policy, on_drop, positioning, wip, frame response"
810
+ ```
811
+
812
+ ```json:metadata
813
+ {"files": ["lib/plutonium/resource/controllers/kanban_actions.rb", "test/integration/admin_portal/kanban_move_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_move_test.rb", "acceptanceCriteria": ["move applies on_drop+position", "kanban_move? false -> 403", "accepts/locked -> 422 unchanged", "wip -> 422", "success replaces from+to frames", "symbol on_drop"], "requiresUserVerification": false}
814
+ ```
815
+
816
+ ---
817
+
818
+ ## Task 8: Column-scoped actions via `interactive_bulk_action`
819
+
820
+ **Goal:** Render a column's actions in its header and route them to the existing `interactive_bulk_action` with the column's card ids (resolved by `on:`).
821
+
822
+ **Files:**
823
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` (id resolution helper), `lib/plutonium/ui/kanban/column.rb` (header buttons — coordinate with Task 9)
824
+ - Test: `test/integration/admin_portal/kanban_column_action_test.rb`
825
+
826
+ **Acceptance Criteria:**
827
+ - [ ] A column `action … on: :all` resolves ids = column scope ∩ current query (all, beyond `per_column`).
828
+ - [ ] `on: :visible` resolves only the rendered, capped ids.
829
+ - [ ] The action links to `…/bulk_actions/:interaction?ids[]=…` (reuses the existing bulk flow + per-record auth).
830
+ - [ ] Header renders only actions whose policy permits them.
831
+
832
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_action_test.rb` → PASS
833
+
834
+ **Steps:**
835
+ - [ ] **Step 1: Failing test** asserting id resolution for `:all` vs `:visible` and the generated bulk URL.
836
+ - [ ] **Step 2: Run → FAIL.**
837
+ - [ ] **Step 3: Implement** `column_action_ids(board, column, on:)` and the header rendering (delegating to existing bulk-action URL helpers).
838
+ - [ ] **Step 4: Run → PASS.**
839
+ - [ ] **Step 5: Commit**
840
+
841
+ ```bash
842
+ git add lib/plutonium/resource/controllers/kanban_actions.rb lib/plutonium/ui/kanban/column.rb test/integration/admin_portal/kanban_column_action_test.rb
843
+ git commit -m "feat(kanban): column-scoped actions via interactive_bulk_action"
844
+ ```
845
+
846
+ ```json:metadata
847
+ {"files": ["lib/plutonium/resource/controllers/kanban_actions.rb", "lib/plutonium/ui/kanban/column.rb", "test/integration/admin_portal/kanban_column_action_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_action_test.rb", "acceptanceCriteria": ["on: :all ids beyond per_column", "on: :visible capped", "links to bulk_actions", "policy-gated header"], "requiresUserVerification": false}
848
+ ```
849
+
850
+ ---
851
+
852
+ ## Task 9: Kanban view components (shell + column + card)
853
+
854
+ **Goal:** Phlex components: `Kanban::Resource` renders the board shell — a row of lazy `<turbo-frame id="kanban-col-<key>" loading="lazy" src=…>` per column (header + lazy body); `Kanban::Column` renders a column's body (cards via `Kanban::Card`, "+N more", wip badge, action header); `Kanban::Card` wraps the existing grid `Card` with `card_fields` slots.
855
+
856
+ **Files:**
857
+ - Create: `lib/plutonium/ui/kanban/resource.rb`, `lib/plutonium/ui/kanban/column.rb`, `lib/plutonium/ui/kanban/card.rb`
858
+ - Test: `test/plutonium/ui/kanban/resource_test.rb`, `test/plutonium/ui/kanban/column_test.rb`
859
+
860
+ **Acceptance Criteria:**
861
+ - [ ] `Resource` renders N lazy turbo-frames with correct `id`/`src`, column headers, and the board's drag controller data attributes.
862
+ - [ ] `Column` renders cards ordered as grouped, a "+N more" when `total > per_column`, and a wip badge `count/limit` when `wip` set.
863
+ - [ ] `Card` reuses `Plutonium::UI::Grid::Components::Card` with `card_fields` (falls back to `grid_fields`).
864
+ - [ ] Collapsed columns render the count-strip variant.
865
+
866
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/resource_test.rb test/plutonium/ui/kanban/column_test.rb` → PASS
867
+
868
+ **Steps:**
869
+ - [ ] **Step 1: Failing tests** rendering the components against a built `Board` + grouped data (Phlex `.call`), asserting frame ids/src, "+N more", wip badge, collapsed variant. Mirror existing Phlex component tests under `test/plutonium/ui/`.
870
+ - [ ] **Step 2: Run → FAIL.**
871
+ - [ ] **Step 3: Implement** the three components, mirroring `lib/plutonium/ui/grid/resource.rb` / `grid/components/card.rb` structure and `.pu-*`/token classes. Use `turbo_scoped_dom_id` for frame ids.
872
+ - [ ] **Step 4:** `yarn build` not needed (no JS yet). Run → PASS.
873
+ - [ ] **Step 5: Commit**
874
+
875
+ ```bash
876
+ git add lib/plutonium/ui/kanban/ test/plutonium/ui/kanban/
877
+ git commit -m "feat(kanban): Phlex board shell, column, and card components"
878
+ ```
879
+
880
+ ```json:metadata
881
+ {"files": ["lib/plutonium/ui/kanban/resource.rb", "lib/plutonium/ui/kanban/column.rb", "lib/plutonium/ui/kanban/card.rb", "test/plutonium/ui/kanban/resource_test.rb", "test/plutonium/ui/kanban/column_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/resource_test.rb", "acceptanceCriteria": ["lazy frames with id/src", "+N more", "wip badge", "card reuses grid card", "collapsed variant"], "requiresUserVerification": false}
882
+ ```
883
+
884
+ ---
885
+
886
+ ## Task 10: Wire into the index page + view switcher
887
+
888
+ **Goal:** Render the board when `selected_view == :kanban`, and add a `kanban` segment to the view switcher.
889
+
890
+ **Files:**
891
+ - Modify: `lib/plutonium/ui/page/index.rb`, `lib/plutonium/ui/table/components/view_switcher.rb`
892
+ - Test: `test/integration/admin_portal/kanban_index_view_test.rb`
893
+
894
+ **Acceptance Criteria:**
895
+ - [ ] `?view=kanban` renders the board shell (lazy frames), not the table.
896
+ - [ ] The switcher shows a `Kanban` segment (icon + label) when `:kanban` is enabled; clicking sets the cookie and reloads.
897
+ - [ ] Cookie stickiness works (existing mechanism).
898
+
899
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_index_view_test.rb` → PASS
900
+
901
+ **Steps:**
902
+ - [ ] **Step 1: Failing test.**
903
+ - [ ] **Step 2: Run → FAIL.**
904
+ - [ ] **Step 3: Implement.** In `index.rb#render_default_content`: `when :kanban then render partial("resource_kanban")` (and add the `resource_kanban` partial method building `Plutonium::UI::Kanban::Resource`). In `view_switcher.rb` add to `SEGMENT_LABELS`: `kanban: {label: "Board", icon: Phlex::TablerIcons::LayoutKanban}`.
905
+ - [ ] **Step 4: Run → PASS.**
906
+ - [ ] **Step 5: Commit**
907
+
908
+ ```bash
909
+ git add lib/plutonium/ui/page/index.rb lib/plutonium/ui/table/components/view_switcher.rb test/integration/admin_portal/kanban_index_view_test.rb
910
+ git commit -m "feat(kanban): render board on index + view-switcher segment"
911
+ ```
912
+
913
+ ```json:metadata
914
+ {"files": ["lib/plutonium/ui/page/index.rb", "lib/plutonium/ui/table/components/view_switcher.rb", "test/integration/admin_portal/kanban_index_view_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_index_view_test.rb", "acceptanceCriteria": ["?view=kanban renders board", "switcher segment", "cookie sticky"], "requiresUserVerification": false}
915
+ ```
916
+
917
+ ---
918
+
919
+ ## Task 11: Stimulus `kanban_controller.js` — drag + move + reconcile
920
+
921
+ **Goal:** A Stimulus controller wiring cross-frame drag (SortableJS-style), posting the move on drop, and letting the frame-scoped response reconcile (failure re-renders unchanged source = snap-back). Registered + built.
922
+
923
+ **Files:**
924
+ - Create: `src/js/controllers/kanban_controller.js`
925
+ - Modify: `src/js/controllers/register_controllers.js`
926
+ - Test: `test/system/kanban_test.rb` (system/browser test) — or an integration assertion that the controller + data attributes are present if system tests are heavy.
927
+
928
+ **Acceptance Criteria:**
929
+ - [ ] Dragging a card to another column POSTs `{from_column,to_column,to_index}` to the move route.
930
+ - [ ] On success the target/source frames update; on a 4xx the card returns to origin (driven by the response, not client bookkeeping).
931
+ - [ ] Controller registered in `register_controllers.js`; `yarn build` produces updated `app/assets/plutonium.js`.
932
+
933
+ **Verify:** `yarn build` then `bundle exec appraisal rails-8.1 ruby -Itest test/system/kanban_test.rb` → PASS (system tests require the JS bundle built).
934
+
935
+ **Steps:**
936
+ - [ ] **Step 1: Failing system test** (drag a card, assert it lands in the new column and persists across reload). Mirror existing `test/system/` setup.
937
+ - [ ] **Step 2: Run → FAIL.**
938
+ - [ ] **Step 3: Implement** the controller (use the project's existing drag dependency if present; otherwise add a lightweight HTML5 drag handler — check `package.json` before adding deps). Register it. `yarn build`.
939
+ - [ ] **Step 4: Run → PASS.**
940
+ - [ ] **Step 5: Commit**
941
+
942
+ ```bash
943
+ git add src/js/controllers/kanban_controller.js src/js/controllers/register_controllers.js app/assets/ test/system/kanban_test.rb
944
+ git commit -m "feat(kanban): drag-to-move Stimulus controller + build"
945
+ ```
946
+
947
+ ```json:metadata
948
+ {"files": ["src/js/controllers/kanban_controller.js", "src/js/controllers/register_controllers.js", "test/system/kanban_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/system/kanban_test.rb", "acceptanceCriteria": ["drag posts move", "response reconciles + snap-back", "registered + built"], "requiresUserVerification": false}
949
+ ```
950
+
951
+ ---
952
+
953
+ ## Task 12: Quick-add (`add: true`)
954
+
955
+ **Goal:** Render an inline "+ Add" on columns with `add: true` that creates a record seeded into the column (apply the column's `on_drop` to a new instance), via the resource's create path; authorized with `create?`.
956
+
957
+ **Files:**
958
+ - Modify: `lib/plutonium/ui/kanban/column.rb`, `lib/plutonium/resource/controllers/kanban_actions.rb`
959
+ - Test: `test/integration/admin_portal/kanban_quick_add_test.rb`
960
+
961
+ **Acceptance Criteria:**
962
+ - [ ] Columns with `add: true` render a "+ Add"; others don't.
963
+ - [ ] Submitting creates a record with the column's `on_drop` applied (e.g. `status: :todo`) and a position at the column end.
964
+ - [ ] `create?` false → no "+ Add", endpoint 403.
965
+
966
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_quick_add_test.rb` → PASS
967
+
968
+ **Steps:** TDD as above. Reuse the resource new/create form (`turbo_frame` modal) seeded with the column placement, or a minimal inline create. Commit:
969
+
970
+ ```bash
971
+ git commit -m "feat(kanban): per-column quick-add seeded via on_drop"
972
+ ```
973
+
974
+ ```json:metadata
975
+ {"files": ["lib/plutonium/ui/kanban/column.rb", "lib/plutonium/resource/controllers/kanban_actions.rb", "test/integration/admin_portal/kanban_quick_add_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_quick_add_test.rb", "acceptanceCriteria": ["add:true renders +Add", "create seeds on_drop + end position", "create? false gates"], "requiresUserVerification": false}
976
+ ```
977
+
978
+ ---
979
+
980
+ ## Task 13: Column behaviour UI — collapse, drop policy, wip badge
981
+
982
+ **Goal:** Finish the column behaviours in the UI/controller: collapsible toggle (initial `collapsed:`), `accepts:`/`locked:` reflected as drag constraints (client) AND enforced server-side (already in Task 7), and the wip badge/over-limit styling.
983
+
984
+ **Files:**
985
+ - Modify: `lib/plutonium/ui/kanban/column.rb`, `src/js/controllers/kanban_controller.js`
986
+ - Test: `test/plutonium/ui/kanban/behaviours_test.rb` + a system assertion for collapse
987
+
988
+ **Acceptance Criteria:**
989
+ - [ ] `collapsed: true` renders folded; a toggle expands (persists per-column via cookie/localStorage — pick one, document it).
990
+ - [ ] `accepts:`/`locked:` set data attributes the controller uses to block disallowed drops client-side (server still enforces).
991
+ - [ ] `wip` badge shows `count/limit`; over-limit gets a warning class.
992
+
993
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/behaviours_test.rb` → PASS
994
+
995
+ **Steps:** TDD. `yarn build` after JS edits. Commit:
996
+
997
+ ```bash
998
+ git commit -m "feat(kanban): column collapse, drop-policy constraints, wip badge"
999
+ ```
1000
+
1001
+ ```json:metadata
1002
+ {"files": ["lib/plutonium/ui/kanban/column.rb", "src/js/controllers/kanban_controller.js", "test/plutonium/ui/kanban/behaviours_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/behaviours_test.rb", "acceptanceCriteria": ["collapse toggle", "accepts/locked client constraints", "wip badge + over-limit"], "requiresUserVerification": false}
1003
+ ```
1004
+
1005
+ ---
1006
+
1007
+ ## Task 14: Opt-in real-time broadcaster
1008
+
1009
+ **Goal:** When `realtime true`, mirror a successful move's frame updates to other viewers via Turbo Streams, scoped to tenant + board. Off by default; no effect on the mover's own rollback.
1010
+
1011
+ **Files:**
1012
+ - Create: `lib/plutonium/kanban/broadcaster.rb`
1013
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` (broadcast after a successful move), `lib/plutonium/ui/kanban/resource.rb` (subscribe via `turbo_stream_from` when realtime)
1014
+ - Test: `test/plutonium/kanban/broadcaster_test.rb`
1015
+
1016
+ **Acceptance Criteria:**
1017
+ - [ ] Stream name includes `current_scoped_entity` + resource class (no cross-tenant leakage).
1018
+ - [ ] Broadcast fires only when `board.realtime?`.
1019
+ - [ ] The board subscribes (`turbo_stream_from`) only when realtime.
1020
+
1021
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/broadcaster_test.rb` → PASS
1022
+
1023
+ **Steps:** TDD; assert broadcast presence/absence and stream-name scoping (use Turbo test helpers / capture). Commit:
1024
+
1025
+ ```bash
1026
+ git commit -m "feat(kanban): opt-in tenant-scoped realtime move broadcasting"
1027
+ ```
1028
+
1029
+ ```json:metadata
1030
+ {"files": ["lib/plutonium/kanban/broadcaster.rb", "lib/plutonium/resource/controllers/kanban_actions.rb", "lib/plutonium/ui/kanban/resource.rb", "test/plutonium/kanban/broadcaster_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/broadcaster_test.rb", "acceptanceCriteria": ["tenant+board scoped stream", "fires only when realtime", "subscribes only when realtime"], "requiresUserVerification": false}
1031
+ ```
1032
+
1033
+ ---
1034
+
1035
+ ## Task 15: Dummy-app fixtures + end-to-end board
1036
+
1037
+ **Goal:** A real board in the dummy app to drive integration/system tests: a `Task`-style resource with `status` + decimal `position`, a definition with a `kanban do…end` (static columns, a wip, a backlog role, a done role + column action), connected to a portal. **Use Plutonium generators** (`pu:res:scaffold`, `pu:res:conn`) — do not hand-write app files.
1038
+
1039
+ **Files:**
1040
+ - Generated under `test/dummy/` (model, migration, definition, policy, controller, routes); then edit the definition to add `kanban do…end`
1041
+ - Test: a small `test/integration/admin_portal/kanban_smoke_test.rb`
1042
+
1043
+ **Acceptance Criteria:**
1044
+ - [ ] `rails g pu:res:scaffold Task title:string status:string position:decimal --dest=…` runs; migration applied; `position` is decimal.
1045
+ - [ ] Definition has a working `kanban` board (static columns + one column action backed by a dummy interaction).
1046
+ - [ ] Smoke test: board renders, a move persists, a column action runs.
1047
+
1048
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_smoke_test.rb` → PASS
1049
+
1050
+ **Steps:**
1051
+ - [ ] **Step 1:** Generate the resource + connect to the admin portal (quote types; pass `--dest=`, `--force` as needed). Edit the migration to confirm `position` is `decimal` and add `Plutonium::Positioning` (`positioned_on :position, scope: :status`) to the model. `rails db:prepare`.
1052
+ - [ ] **Step 2:** Add the `kanban do…end` block to `TaskDefinition` (mirror the spec's §3 example, scaled down). Add a trivial `ArchiveTasks` interaction for the column action.
1053
+ - [ ] **Step 3:** Write + run the smoke test → PASS.
1054
+ - [ ] **Step 4: Commit**
1055
+
1056
+ ```bash
1057
+ git add test/dummy/ test/integration/admin_portal/kanban_smoke_test.rb
1058
+ git commit -m "test(kanban): dummy Task board fixture + smoke test"
1059
+ ```
1060
+
1061
+ > **Reorder note:** Tasks 6–13 integration tests depend on this fixture. If using subagent-driven execution, consider running Task 15 right after Task 5 (before the controller/UI tasks). Listed last only to keep the core/lib tasks contiguous.
1062
+
1063
+ ```json:metadata
1064
+ {"files": ["test/dummy/app/models/task.rb", "test/dummy/app/definitions/task_definition.rb", "test/integration/admin_portal/kanban_smoke_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_smoke_test.rb", "acceptanceCriteria": ["scaffolded Task w/ decimal position", "kanban board in definition", "smoke: render+move+action"], "requiresUserVerification": false}
1065
+ ```
1066
+
1067
+ ---
1068
+
1069
+ ## Task 16: Documentation (guide + reference)
1070
+
1071
+ **Goal:** A `docs/guides/kanban.md` guide and `docs/reference/kanban/*` reference mirroring the wizard docs, plus nav wiring in `docs/.vitepress/config.ts` and `docs/guides/index.md`/`docs/reference/index.md`.
1072
+
1073
+ **Files:**
1074
+ - Create: `docs/guides/kanban.md`, `docs/reference/kanban/index.md`, `docs/reference/kanban/dsl.md`, `docs/reference/kanban/positioning.md`, `docs/reference/kanban/authorization.md`
1075
+ - Modify: `docs/.vitepress/config.ts`, `docs/guides/index.md`, `docs/reference/index.md`
1076
+
1077
+ **Acceptance Criteria:**
1078
+ - [ ] Guide covers: enabling the board, static vs dynamic columns, positioning modes, behaviours/archetypes, column actions, authorization, realtime.
1079
+ - [ ] `yarn docs:build` succeeds (no broken links).
1080
+
1081
+ **Verify:** `yarn docs:build` → exits 0
1082
+
1083
+ **Steps:** Write docs from the spec (DSL examples are already validated). Run `yarn docs:build`. Commit:
1084
+
1085
+ ```bash
1086
+ git commit -m "docs(kanban): guide + reference"
1087
+ ```
1088
+
1089
+ ```json:metadata
1090
+ {"files": ["docs/guides/kanban.md", "docs/reference/kanban/index.md", "docs/reference/kanban/dsl.md", "docs/.vitepress/config.ts"], "verifyCommand": "yarn docs:build", "acceptanceCriteria": ["guide covers all features", "docs build clean"], "requiresUserVerification": false}
1091
+ ```
1092
+
1093
+ ---
1094
+
1095
+ ## Task 17: `plutonium-kanban` skill + router entry
1096
+
1097
+ **Goal:** A `.claude/skills/plutonium-kanban/SKILL.md` (mirroring `plutonium-wizard`) and a router entry + table row in `.claude/skills/plutonium/SKILL.md`.
1098
+
1099
+ **Files:**
1100
+ - Create: `.claude/skills/plutonium-kanban/SKILL.md`
1101
+ - Modify: `.claude/skills/plutonium/SKILL.md`
1102
+ - Test: none (docs); verify by review.
1103
+
1104
+ **Acceptance Criteria:**
1105
+ - [ ] Skill describes when to use it, the DSL surface, and links to docs/spec.
1106
+ - [ ] Router table in `plutonium/SKILL.md` has a "build a kanban board" → `plutonium-kanban` row.
1107
+
1108
+ **Verify:** Manual review; `git grep -n "plutonium-kanban" .claude/skills/plutonium/SKILL.md` shows the entry.
1109
+
1110
+ **Steps:** Write the skill mirroring the wizard skill's structure. Commit:
1111
+
1112
+ ```bash
1113
+ git commit -m "docs(kanban): plutonium-kanban skill + router entry"
1114
+ ```
1115
+
1116
+ ```json:metadata
1117
+ {"files": [".claude/skills/plutonium-kanban/SKILL.md", ".claude/skills/plutonium/SKILL.md"], "verifyCommand": "git grep -n plutonium-kanban .claude/skills/plutonium/SKILL.md", "acceptanceCriteria": ["skill written", "router entry added"], "requiresUserVerification": false}
1118
+ ```
1119
+
1120
+ ---
1121
+
1122
+ ## Final verification (after all tasks)
1123
+
1124
+ - [ ] `bundle exec appraisal rails-8.1 rake test` → all green
1125
+ - [ ] `bundle exec appraisal rails-7 rake test` and `rails-8.0` → green (version parity)
1126
+ - [ ] `yarn build` clean; `yarn docs:build` clean
1127
+ - [ ] Manually drive the dummy board (see `memory/reference_driving_dummy_app_browser.md`): load `?view=kanban`, drag a card, run a column action, quick-add, toggle collapse.
1128
+ - [ ] Re-read the spec §2 decisions; confirm each has a landing task.