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,212 @@
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
+ attr_reader :board, :grouped_data, :resource_definition, :resource_fields,
28
+ :resource_class, :scoped_entity
29
+
30
+ def initialize(board:, grouped_data:, resource_definition:, resource_fields:,
31
+ resource_class: nil, scoped_entity: nil)
32
+ @board = board
33
+ @grouped_data = grouped_data
34
+ @resource_definition = resource_definition
35
+ # TODO (Tasks 6/10): resource_fields arrives ALREADY resolved — the
36
+ # column endpoint / index page is responsible for the
37
+ # `board.card_fields || definition.grid_fields` fallback before
38
+ # constructing this component. Kanban::Resource (and the Card it
39
+ # eventually builds) just receives the final field list and renders
40
+ # it; it does not resolve card_fields itself.
41
+ @resource_fields = resource_fields
42
+ # Used by render_realtime_subscription (Task 14) to scope the
43
+ # ActionCable stream to the correct tenant + resource.
44
+ @resource_class = resource_class
45
+ @scoped_entity = scoped_entity
46
+ end
47
+
48
+ def view_template
49
+ render_realtime_subscription if board.realtime?
50
+
51
+ # Wrap in the filter-panel controller so the toolbar's Filter button
52
+ # can open the slideover — same structure as Grid/Table::Resource, so
53
+ # the board carries the shared view switcher (Table | Grid | Board),
54
+ # search, scopes, and filters rather than rendering bare columns.
55
+ div(data: filter_panel_controller_data) do
56
+ # Persistent append target for move-rejection toasts. It lives
57
+ # OUTSIDE the per-column turbo-frames so it survives the frame swaps
58
+ # a move triggers; toasts are position:fixed, so its location in the
59
+ # DOM has no layout effect. The move handler appends a flash toast
60
+ # here via turbo_stream when a drop is rejected (WIP / accepts /
61
+ # locked) so the snap-back is explained rather than silent.
62
+ div(id: "kanban-flash")
63
+ render_scopes_pills
64
+ render_toolbar
65
+
66
+ div(
67
+ class: "pu-kanban-board flex gap-4 overflow-x-auto p-4 min-h-0",
68
+ data: {
69
+ controller: "kanban",
70
+ # Stimulus value consumed by the drag controller to build the
71
+ # per-record move URL at drop time. The collection path comes from
72
+ # request.path so tenant / engine scoping is preserved automatically.
73
+ # Example: /admin/tasks/__ID__/kanban_move
74
+ kanban_move_url_template_value: kanban_move_url_template
75
+ }
76
+ ) do
77
+ grouped_data.each do |entry|
78
+ render_column_frame(entry[:column])
79
+ end
80
+ end
81
+
82
+ render_filter_slideover if current_query_object.filter_definitions.present?
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def filter_panel_controller_data
89
+ {controller: "filter-panel"}
90
+ end
91
+
92
+ def render_scopes_pills
93
+ TableScopesPills() if current_query_object.scope_definitions.any?
94
+ end
95
+
96
+ # The shared index toolbar — view switcher (Table | Grid | Board),
97
+ # search, and the filter button. `current_view: :kanban` keeps the
98
+ # Board segment highlighted.
99
+ def render_toolbar
100
+ TableToolbar(
101
+ query: current_query_object,
102
+ search_url: request.path,
103
+ search_value: params.dig(:q, :search) || params[:search],
104
+ views: resource_definition.defined_index_views,
105
+ current_view: :kanban,
106
+ view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
107
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
108
+ )
109
+ end
110
+
111
+ def render_filter_slideover
112
+ div(
113
+ class: "fixed inset-0 z-40 bg-black/40 opacity-0 pointer-events-none " \
114
+ "transition-opacity duration-200 " \
115
+ "data-[open]:opacity-100 data-[open]:pointer-events-auto",
116
+ data: {filter_panel_target: "backdrop", action: "click->filter-panel#close"}
117
+ )
118
+ aside(
119
+ class: "fixed top-0 right-0 bottom-0 z-50 w-full sm:w-[420px] max-w-full " \
120
+ "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
121
+ "translate-x-full transition-transform duration-300 ease-out " \
122
+ "data-[open]:translate-x-0 " \
123
+ "flex flex-col",
124
+ role: "dialog",
125
+ aria: {label: "Filters", hidden: "true", modal: "true"},
126
+ data: {filter_panel_target: "panel"}
127
+ ) do
128
+ render Plutonium::UI::Table::Components::FilterForm.new(
129
+ filter_form_values,
130
+ query_object: current_query_object,
131
+ search_url: request.path,
132
+ search_value: params.dig(:q, :search) || params[:search]
133
+ )
134
+ end
135
+ end
136
+
137
+ def filter_form_values
138
+ raw = params[:q]
139
+ return {} unless raw
140
+ hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
141
+ hash.deep_symbolize_keys.except(:search, :scope, :sort_fields, :sort_directions)
142
+ end
143
+
144
+ # Emits a <turbo-cable-stream-source> element that subscribes this page
145
+ # to the kanban board's tenant-scoped ActionCable stream.
146
+ #
147
+ # Only called when board.realtime? is true (gated by the caller in
148
+ # view_template). The stream name matches the one used by
149
+ # Plutonium::Kanban::Broadcaster#broadcast so move events reach exactly
150
+ # the right subscribers.
151
+ def render_realtime_subscription
152
+ turbo_stream_from(
153
+ *Plutonium::Kanban::Broadcaster.stream_name(
154
+ resource_class: resource_class,
155
+ scoped_entity: scoped_entity
156
+ )
157
+ )
158
+ end
159
+
160
+ def render_column_frame(column)
161
+ attrs = {src: column_frame_src(column)}
162
+ attrs[:loading] = "lazy" if board.lazy?
163
+
164
+ turbo_frame_tag(column_frame_id(column), **attrs) do
165
+ # Header is inside the frame so the shell is meaningful while the
166
+ # body (card list) loads. Task 6 replaces the frame contents with
167
+ # the full column body rendered by Kanban::Column.
168
+ render_column_header(column)
169
+ end
170
+ end
171
+
172
+ # Mirrors Kanban::Column#render_header structurally (no count badge) so
173
+ # the shell→loaded transition doesn't flash a stale count or restructure.
174
+ def render_column_header(column)
175
+ div(class: "px-3 py-2 flex items-center justify-between border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
176
+ div(class: "flex items-center gap-2 min-w-0") do
177
+ render_color_dot(column.color) if column.color
178
+ span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
179
+ end
180
+ end
181
+ end
182
+
183
+ # Builds the move URL template for the Stimulus drag controller.
184
+ # The collection path from the current request is used so engine
185
+ # mounting and path-scoped tenancy are automatically preserved.
186
+ # The literal string "__ID__" is a placeholder; the JS controller
187
+ # replaces it with the dragged card's record id at drop time.
188
+ def kanban_move_url_template
189
+ base = request.path.delete_suffix("/")
190
+ "#{base}/__ID__/kanban_move"
191
+ end
192
+
193
+ # Returns the turbo-frame element id for a column.
194
+ # Plain id — not turbo_scoped_dom_id which is for modal-frame scoping.
195
+ def column_frame_id(column)
196
+ "kanban-col-#{column.key}"
197
+ end
198
+
199
+ # Builds the frame src URL from the current request path + merged params.
200
+ # Merges view=kanban and column=<key> so the column endpoint (Task 6)
201
+ # knows which column to render without requiring an explicit route param.
202
+ def column_frame_src(column)
203
+ merged = request.query_parameters.merge(
204
+ "view" => "kanban",
205
+ "column" => column.key.to_s
206
+ )
207
+ "#{request.path}?#{merged.to_query}"
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -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
@@ -38,6 +38,7 @@ module Plutonium
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
@@ -19,6 +19,12 @@ module Plutonium
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,23 @@ 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
+ render partial("resource_details")
52
+ end
53
+ end
54
+
32
55
  def page_type = :show_page
33
56
  end
34
57
  end