plutonium 0.60.5 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. metadata +113 -2
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Board shell — renders one lazy turbo-frame per column.
7
+ #
8
+ # Each frame id is "kanban-col-<key>". Its src merges view=kanban and
9
+ # column=<key> into the current request URL so Task 6's column endpoint
10
+ # fills the body on demand. The frame already contains the column header
11
+ # (color dot + label) so the shell is meaningful while the body loads. The
12
+ # header deliberately omits a card-count badge: the shell has no card data,
13
+ # so a "0" would flash then vanish when Kanban::Column (which renders no
14
+ # count badge) replaces the frame body.
15
+ #
16
+ # The outer wrapper carries data-controller="kanban" for the Stimulus
17
+ # drag controller wired in Task 11.
18
+ #
19
+ # Realtime subscription: when board.realtime? is true,
20
+ # render_realtime_subscription is called before the board wrapper.
21
+ # Task 14 implements the broadcaster and fills in that method body.
22
+ class Resource < Plutonium::UI::Component::Base
23
+ include ColorDot
24
+ include Phlex::Rails::Helpers::TurboFrameTag
25
+ include Phlex::Rails::Helpers::TurboStreamFrom
26
+
27
+ # Per-board collapse cookie. Stores ONLY the column keys whose collapse
28
+ # state differs from the server default (a compact delta): a board sitting
29
+ # at its defaults writes nothing, and toggling a column back to default
30
+ # drops its key — so the cookie can't balloon. Path-scoped to the engine
31
+ # mount so it rides only this board's requests. The server reads it to
32
+ # render each column in the user's state (no client re-apply / FOUC); the
33
+ # kanban controller writes it on toggle.
34
+ def self.collapse_cookie_name(resource_class)
35
+ "pu_kbc_#{resource_class.name.gsub("::", "_").underscore}"
36
+ end
37
+
38
+ def self.collapse_cookie_path(request)
39
+ path = request.script_name.to_s
40
+ path.empty? ? "/" : path
41
+ end
42
+
43
+ # The set of column keys (strings) flipped from their default, parsed
44
+ # from the cookie value. Order/whitespace-tolerant; blanks dropped.
45
+ def self.collapse_flips(cookie_value)
46
+ cookie_value.to_s.split(",").filter_map { |k| k.strip.presence }
47
+ end
48
+
49
+ attr_reader :board, :grouped_data, :resource_definition, :resource_fields,
50
+ :resource_class, :scoped_entity
51
+
52
+ def initialize(board:, grouped_data:, resource_definition:, resource_fields:,
53
+ resource_class: nil, scoped_entity: nil)
54
+ @board = board
55
+ @grouped_data = grouped_data
56
+ @resource_definition = resource_definition
57
+ # TODO (Tasks 6/10): resource_fields arrives ALREADY resolved — the
58
+ # column endpoint / index page is responsible for the
59
+ # `board.card_fields || definition.grid_fields` fallback before
60
+ # constructing this component. Kanban::Resource (and the Card it
61
+ # eventually builds) just receives the final field list and renders
62
+ # it; it does not resolve card_fields itself.
63
+ @resource_fields = resource_fields
64
+ # Used by render_realtime_subscription (Task 14) to scope the
65
+ # ActionCable stream to the correct tenant + resource.
66
+ @resource_class = resource_class
67
+ @scoped_entity = scoped_entity
68
+ end
69
+
70
+ def view_template
71
+ render_realtime_subscription if board.realtime?
72
+
73
+ # Wrap in the filter-panel controller so the toolbar's Filter button
74
+ # can open the slideover — same structure as Grid/Table::Resource, so
75
+ # the board carries the shared view switcher (Table | Grid | Board),
76
+ # search, scopes, and filters rather than rendering bare columns.
77
+ div(data: filter_panel_controller_data) do
78
+ # Persistent append target for move-rejection toasts. It lives
79
+ # OUTSIDE the per-column turbo-frames so it survives the frame swaps
80
+ # a move triggers; toasts are position:fixed, so its location in the
81
+ # DOM has no layout effect. The move handler appends a flash toast
82
+ # here via turbo_stream when a drop is rejected (WIP / accepts /
83
+ # locked) so the snap-back is explained rather than silent.
84
+ div(id: "kanban-flash")
85
+ render_scopes_pills
86
+ render_toolbar
87
+
88
+ div(
89
+ # `data-turbo-permanent` freezes the loaded board across index
90
+ # navigations (search / filter / scope all change the URL): Turbo
91
+ # transplants THIS element instead of re-rendering it as empty lazy
92
+ # shells, so the columns never blank. The `kanban` controller then
93
+ # syncs the frozen frames to the new URL (morphing cards in place).
94
+ # The id MUST be resource-scoped — a shared id would make Turbo
95
+ # preserve one resource's board when navigating to another's.
96
+ id: "kanban-board-#{resource_class.model_name.param_key}",
97
+ class: "pu-kanban-board flex gap-4 overflow-x-auto p-4 min-h-0",
98
+ data: {
99
+ turbo_permanent: true,
100
+ controller: "kanban",
101
+ # Stimulus value consumed by the drag controller to build the
102
+ # per-record move URL at drop time. The collection path comes from
103
+ # request.path so tenant / engine scoping is preserved automatically.
104
+ # Example: /admin/tasks/__ID__/kanban_move
105
+ kanban_move_url_template_value: kanban_move_url_template,
106
+ # Name + path the controller uses to write the collapse cookie —
107
+ # must match what the server reads (see collapse_cookie_name/path).
108
+ kanban_collapse_cookie_value: self.class.collapse_cookie_name(resource_class),
109
+ kanban_collapse_path_value: self.class.collapse_cookie_path(request)
110
+ }
111
+ ) do
112
+ grouped_data.each do |entry|
113
+ render_column_frame(entry[:column], collapsed: entry[:collapsed])
114
+ end
115
+ end
116
+
117
+ render_filter_slideover if current_query_object.filter_definitions.present?
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def filter_panel_controller_data
124
+ {controller: "filter-panel"}
125
+ end
126
+
127
+ def render_scopes_pills
128
+ TableScopesPills() if current_query_object.scope_definitions.any?
129
+ end
130
+
131
+ # The shared index toolbar — view switcher (Table | Grid | Board),
132
+ # search, and the filter button. `current_view: :kanban` keeps the
133
+ # Board segment highlighted.
134
+ def render_toolbar
135
+ TableToolbar(
136
+ query: current_query_object,
137
+ search_url: request.path,
138
+ search_value: params.dig(:q, :search) || params[:search],
139
+ views: resource_definition.defined_index_views,
140
+ current_view: :kanban,
141
+ view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
142
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
143
+ )
144
+ end
145
+
146
+ def render_filter_slideover
147
+ div(
148
+ class: "fixed inset-0 z-40 bg-black/40 opacity-0 pointer-events-none " \
149
+ "transition-opacity duration-200 " \
150
+ "data-[open]:opacity-100 data-[open]:pointer-events-auto",
151
+ data: {filter_panel_target: "backdrop", action: "click->filter-panel#close"}
152
+ )
153
+ aside(
154
+ class: "fixed top-0 right-0 bottom-0 z-50 w-full sm:w-[420px] max-w-full " \
155
+ "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
156
+ "translate-x-full transition-transform duration-300 ease-out " \
157
+ "data-[open]:translate-x-0 " \
158
+ "flex flex-col",
159
+ role: "dialog",
160
+ aria: {label: "Filters", hidden: "true", modal: "true"},
161
+ data: {filter_panel_target: "panel"}
162
+ ) do
163
+ render Plutonium::UI::Table::Components::FilterForm.new(
164
+ filter_form_values,
165
+ query_object: current_query_object,
166
+ search_url: request.path,
167
+ search_value: params.dig(:q, :search) || params[:search]
168
+ )
169
+ end
170
+ end
171
+
172
+ def filter_form_values
173
+ raw = params[:q]
174
+ return {} unless raw
175
+ hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
176
+ hash.deep_symbolize_keys.except(:search, :scope, :sort_fields, :sort_directions)
177
+ end
178
+
179
+ # Emits a <turbo-cable-stream-source> element that subscribes this page
180
+ # to the kanban board's tenant-scoped ActionCable stream.
181
+ #
182
+ # Only called when board.realtime? is true (gated by the caller in
183
+ # view_template). The stream name matches the one used by
184
+ # Plutonium::Kanban::Broadcaster#broadcast so move events reach exactly
185
+ # the right subscribers.
186
+ def render_realtime_subscription
187
+ turbo_stream_from(
188
+ *Plutonium::Kanban::Broadcaster.stream_name(
189
+ resource_class: resource_class,
190
+ scoped_entity: scoped_entity
191
+ )
192
+ )
193
+ end
194
+
195
+ def render_column_frame(column, collapsed: false)
196
+ # `refresh: "morph"` marks the frame morphable; `data-kanban-col-frame`
197
+ # lets the `kanban` controller find each column frame and rewrite its
198
+ # src to the current URL params (search / filter / scope) so cards
199
+ # morph in place instead of the frame blanking on reload.
200
+ attrs = {
201
+ src: column_frame_src(column),
202
+ refresh: "morph",
203
+ data: {kanban_col_frame: column.key}
204
+ }
205
+ attrs[:loading] = "lazy" if board.lazy?
206
+
207
+ turbo_frame_tag(column_frame_id(column), **attrs) do
208
+ # The placeholder mirrors the SHAPE of the loaded column (a thin strip
209
+ # when collapsed, the full w-72 box otherwise) so the lazy body load
210
+ # doesn't snap between orientations/widths — a collapsed column that
211
+ # renders as an open header and then flips shut is the FOUC this fixes.
212
+ if collapsed
213
+ render_collapsed_placeholder(column)
214
+ else
215
+ render_column_placeholder(column)
216
+ end
217
+ end
218
+ end
219
+
220
+ # Full-column placeholder: matches the loaded expanded column box (w-72)
221
+ # so an expanded column doesn't resize when its body arrives.
222
+ def render_column_placeholder(column)
223
+ div(class: "pu-kanban-column w-72 shrink-0 flex flex-col bg-[var(--pu-surface-alt)] " \
224
+ "border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden") do
225
+ render_column_header(column)
226
+ end
227
+ end
228
+
229
+ # Strip placeholder: matches the loaded collapsed strip's full layout
230
+ # (w-10, justify-between, dot + vertical label + count badge + expand
231
+ # icon) so a collapsed column paints as a strip from the first frame AND
232
+ # doesn't grow/redistribute when the real strip (with its count + button)
233
+ # swaps in. The count/icon are reserved as invisible-/subtle skeletons.
234
+ def render_collapsed_placeholder(column)
235
+ div(
236
+ class: "pu-kanban-strip w-10 shrink-0 flex flex-col items-center justify-between py-3 gap-2 " \
237
+ "bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
238
+ "rounded-[var(--pu-radius-md)] select-none",
239
+ data: {kanban_role: "strip"}
240
+ ) do
241
+ render_color_dot(column.color) if column.color
242
+ span(
243
+ class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
244
+ "[writing-mode:vertical-lr] rotate-180"
245
+ ) { plain column.label }
246
+ # The strip ALWAYS shows a card-count badge + expand button when
247
+ # loaded, so reserve both (unlike the header's wip-only count).
248
+ span(class: "pu-badge pu-badge-neutral text-xs font-mono opacity-0", aria_hidden: "true") { plain "0" }
249
+ span(class: "p-0.5 text-[var(--pu-text-subtle)]", aria_hidden: "true") { plain "▶" }
250
+ end
251
+ end
252
+
253
+ # Mirrors Kanban::Column#render_header's LAYOUT (dot + label, plus reserved
254
+ # slots for the count badge and the collapse icon) so the header keeps its
255
+ # exact size when the loaded body swaps in — nothing shifts or grows. The
256
+ # reserved slots are invisible skeletons; the real count/icon replace them
257
+ # on load without a reflow.
258
+ def render_column_header(column)
259
+ div(class: "px-3 py-2 flex items-center justify-between gap-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
260
+ div(class: "flex items-center gap-2 min-w-0 flex-1") do
261
+ render_color_dot(column.color) if column.color
262
+ span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
263
+ # The loaded header always shows a count now — a plain count, or a
264
+ # "count/limit" badge for wip columns — so reserve it either way,
265
+ # sized to the eventual text.
266
+ render_count_placeholder(column.wip ? "0/0" : "0")
267
+ end
268
+ render_icon_placeholder
269
+ end
270
+ end
271
+
272
+ # Invisible badge skeleton reserving the count badge's height and
273
+ # approximate width so the label doesn't reflow when the count appears.
274
+ def render_count_placeholder(text)
275
+ span(
276
+ class: "pu-badge pu-badge-neutral text-xs font-mono opacity-0",
277
+ aria_hidden: "true"
278
+ ) { plain text }
279
+ end
280
+
281
+ # Reserves the collapse toggle's footprint (the loaded header always has
282
+ # one on the right) so the row height and label width don't change on load.
283
+ def render_icon_placeholder
284
+ span(
285
+ class: "shrink-0 p-0.5 text-[var(--pu-text-subtle)]",
286
+ aria_hidden: "true"
287
+ ) { plain "◀" }
288
+ end
289
+
290
+ # Builds the move URL template for the Stimulus drag controller.
291
+ # The collection path from the current request is used so engine
292
+ # mounting and path-scoped tenancy are automatically preserved.
293
+ # The literal string "__ID__" is a placeholder; the JS controller
294
+ # replaces it with the dragged card's record id at drop time.
295
+ def kanban_move_url_template
296
+ base = request.path.delete_suffix("/")
297
+ "#{base}/__ID__/kanban_move"
298
+ end
299
+
300
+ # Returns the turbo-frame element id for a column.
301
+ # Plain id — not turbo_scoped_dom_id which is for modal-frame scoping.
302
+ def column_frame_id(column)
303
+ "kanban-col-#{column.key}"
304
+ end
305
+
306
+ # Builds the frame src URL from the current request path + merged params.
307
+ # Merges view=kanban and column=<key> so the column endpoint (Task 6)
308
+ # knows which column to render without requiring an explicit route param.
309
+ def column_frame_src(column)
310
+ merged = request.query_parameters.merge(
311
+ "view" => "kanban",
312
+ "column" => column.key.to_s
313
+ )
314
+ "#{request.path}?#{merged.to_query}"
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
@@ -27,7 +27,7 @@ module Plutonium
27
27
 
28
28
  def html_attributes = {lang:, data_controller: "color-mode"}
29
29
 
30
- def body_attributes = {class: "antialiased min-h-screen bg-[var(--pu-body)]"}
30
+ def body_attributes = {class: "antialiased min-h-screen bg-[var(--pu-body)] text-[var(--pu-text)]"}
31
31
 
32
32
  def main_attributes = {class: "p-4 min-h-screen"}
33
33
 
@@ -61,11 +61,17 @@ module Plutonium
61
61
  )
62
62
  end
63
63
 
64
+ # The classic shell always renders its sidebar; the modern family
65
+ # renders the icon rail only when the rail is active. Mirrors the
66
+ # classic special-casing in main_attributes/html_attributes, whose
67
+ # offsets are likewise rail-independent for :classic.
68
+ def render_sidebar? = shell == :classic || rail?
69
+
64
70
  def render_before_main
65
71
  super
66
72
 
67
73
  render partial("resource_header")
68
- render partial("resource_sidebar") if rail?
74
+ render partial("resource_sidebar") if render_sidebar?
69
75
  end
70
76
  end
71
77
  end
@@ -24,10 +24,14 @@ module Plutonium
24
24
  (mode == :centered) ? Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
25
25
  end
26
26
 
27
- def initialize(title: nil, description: nil, size: :md)
27
+ def initialize(title: nil, description: nil, size: :md, open_full_url: nil)
28
28
  @title = title
29
29
  @description = description
30
30
  @size = size
31
+ # When set, the header shows an "open full page" affordance that opens
32
+ # this URL in a new tab as a standalone page (no modal frame). The show
33
+ # modal uses it so a user can pop the record out to its full page.
34
+ @open_full_url = open_full_url
31
35
  validate_size!
32
36
  end
33
37
 
@@ -106,7 +110,30 @@ module Plutonium
106
110
  p(id: description_id, class: "mt-1 text-sm text-[var(--pu-text-muted)]") { @description }
107
111
  end
108
112
  end
109
- render_close_button
113
+ # The corner-hugging offset lives on the GROUP (not the individual
114
+ # buttons) so the buttons' own margins don't collapse the gap when
115
+ # there are two of them (open-full + close).
116
+ div(class: "flex items-center gap-1 shrink-0 -mt-1.5 -mr-1.5") do
117
+ render_open_full_link if @open_full_url
118
+ render_close_button
119
+ end
120
+ end
121
+ end
122
+
123
+ # Opens the modal's content as a standalone full page in a new tab.
124
+ # target=_blank gives a fresh browsing context with no Turbo-Frame
125
+ # header, so the destination renders full-page rather than re-entering
126
+ # the modal frame.
127
+ def render_open_full_link
128
+ a(
129
+ href: @open_full_url,
130
+ target: "_blank",
131
+ rel: "noopener",
132
+ class: "p-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
133
+ "aria-label": "Open full page in a new tab",
134
+ title: "Open full page"
135
+ ) do
136
+ render Phlex::TablerIcons::ArrowsDiagonal.new(class: "w-5 h-5")
110
137
  end
111
138
  end
112
139
 
@@ -116,7 +143,7 @@ module Plutonium
116
143
  else
117
144
  button(
118
145
  type: "button",
119
- class: "p-1.5 -m-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
146
+ class: "p-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
120
147
  data: {action: "remote-modal#close"},
121
148
  "aria-label": "Close dialog"
122
149
  ) do
@@ -22,16 +22,19 @@ module Plutonium
22
22
  # Surface (bg, border, radius, backdrop) lives in `.pu-dialog` so
23
23
  # the centered modal, dirty-form-guard prompt, and Turbo confirm
24
24
  # can't drift on design tokens. The remaining utilities are
25
- # positioning, sizing, and the open/close transform animation —
25
+ # positioning, sizing, and the open/close opacity+scale animation —
26
26
  # driven by [data-open] (set on the frame after showModal() by
27
27
  # remote_modal_controller); avoids the @starting-style spec dance.
28
+ # The transition must name `scale` (not `transform`): Tailwind v4's
29
+ # `scale-*` sets the discrete `scale` CSS property, so a
30
+ # `transition-[...,transform]` would leave the scale pop un-animated.
28
31
  def base_dialog_classes
29
32
  "pu-dialog " \
30
33
  "top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
31
34
  "max-h-[80vh] " \
32
35
  "open:flex flex-col p-0 " \
33
36
  "opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
34
- "transition-[opacity,transform] duration-200 ease-out"
37
+ "transition-[opacity,scale] duration-200 ease-out"
35
38
  end
36
39
  end
37
40
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Shared detector for a `has_cents` money field, so the form and display
7
+ # inferred-type chains agree on what counts as currency (an explicit
8
+ # `as: :currency` is never needed for a `has_cents` attribute). Included
9
+ # into both `Form::Options::InferredTypes` and `Display::Options::InferredTypes`.
10
+ module HasCentsField
11
+ private
12
+
13
+ # Whether the field being rendered is a `has_cents` decimal accessor.
14
+ def has_cents_field?
15
+ klass = object.class
16
+ klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(key)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -33,11 +33,12 @@ module Plutonium
33
33
  end
34
34
 
35
35
  def page_actions
36
- super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
36
+ super || current_definition.defined_actions.values.select { |a| a.resource_action? && !a.kanban_drop? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
37
37
  end
38
38
 
39
39
  def render_default_content
40
40
  case selected_view
41
+ when :kanban then render partial("resource_kanban")
41
42
  when :grid then render partial("resource_grid")
42
43
  else render partial("resource_table")
43
44
  end
@@ -27,13 +27,23 @@ module Plutonium
27
27
  description: page_description,
28
28
  size: current_interactive_action.modal_size(current_definition)
29
29
  ) do
30
- render partial("interactive_action_form")
30
+ render_interactive_action_form
31
31
  end
32
32
  else
33
- div(class: "pb-20") { render partial("interactive_action_form") }
33
+ div(class: "pb-20") { render_interactive_action_form }
34
34
  end
35
35
  end
36
36
 
37
+ # Renders the interaction's form inside the modal/page chrome.
38
+ # Extracted as a seam so subclasses (e.g. the kanban drop-move page)
39
+ # can swap in a form that posts elsewhere and carries extra context
40
+ # without duplicating the modal chrome above.
41
+ def render_interactive_action_form
42
+ render partial(interactive_action_form_partial)
43
+ end
44
+
45
+ def interactive_action_form_partial = "interactive_action_form"
46
+
37
47
  def page_type = :interactive_action_page
38
48
  end
39
49
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Page
6
+ # Modal page shown when a card is dropped into a column that declares a
7
+ # `enter_interaction:`. It reuses the interactive-action modal chrome
8
+ # (title/description/modal mode all come from the enter_interaction's
9
+ # auto-registered record action) but renders a form that POSTs to the
10
+ # `kanban_move` member route instead of the interaction's own commit URL,
11
+ # carrying the move context (from_column/to_column/to_index) as hidden
12
+ # fields alongside the interaction's own inputs.
13
+ class KanbanMove < InteractiveAction
14
+ private
15
+
16
+ def interactive_action_form_partial = "kanban_move_action_form"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -15,10 +15,16 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def page_actions
18
- super || current_definition.defined_actions.values.select { |a| a.record_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
18
+ super || current_definition.defined_actions.values.select { |a| a.record_action? && !a.kanban_drop? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
19
19
  end
20
20
 
21
21
  def render_default_content
22
+ # When the show request arrives via a modal frame (e.g. a kanban card
23
+ # with `show_in :modal`), wrap the details in the modal chrome — the
24
+ # same path New/Edit use for their forms. The aside is dropped in the
25
+ # modal: a slideover/centered dialog has no room for a side rail.
26
+ return render_modal_details if in_modal?
27
+
22
28
  if aside_present?
23
29
  div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_240px] gap-6") do
24
30
  div { render partial("resource_details") }
@@ -29,6 +35,28 @@ module Plutonium
29
35
  end
30
36
  end
31
37
 
38
+ # The show page is ALWAYS a centered dialog when shown in a modal —
39
+ # deliberately not the definition's modal_mode (which styles :new/:edit
40
+ # as a slideover by default). A centered dialog reads as a focused
41
+ # "detail card" and leaves a launching board/table visible around it.
42
+ # `open_full_url` is the current show URL (request.path is the show
43
+ # route here), letting the user pop the record out to its full page.
44
+ def render_modal_details
45
+ render Plutonium::UI::Modal::Centered.new(
46
+ title: page_title,
47
+ description: page_description,
48
+ size: :lg,
49
+ open_full_url: request.path
50
+ ) do
51
+ # The modal body owns no padding — content provides its own (the
52
+ # form uses this same padded, scrollable region). Without it the
53
+ # detail cards sit flush against the modal edges.
54
+ div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5") do
55
+ render partial("resource_details")
56
+ end
57
+ end
58
+ end
59
+
32
60
  def page_type = :show_page
33
61
  end
34
62
  end