plutonium 0.61.0 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
  3. data/CHANGELOG.md +27 -0
  4. data/app/assets/plutonium.css +1 -1
  5. data/app/assets/plutonium.js +315 -38
  6. data/app/assets/plutonium.js.map +4 -4
  7. data/app/assets/plutonium.min.js +31 -31
  8. data/app/assets/plutonium.min.js.map +4 -4
  9. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  10. data/app/views/resource/kanban_move_form.html.erb +1 -0
  11. data/config/brakeman.ignore +2 -2
  12. data/docs/.vitepress/config.ts +21 -1
  13. data/docs/.vitepress/sync-skills.mjs +45 -0
  14. data/docs/ai.md +99 -0
  15. data/docs/guides/kanban.md +128 -18
  16. data/docs/reference/kanban/authorization.md +25 -5
  17. data/docs/reference/kanban/dsl.md +49 -8
  18. data/docs/reference/kanban/index.md +3 -3
  19. data/docs/reference/kanban/positioning.md +1 -1
  20. data/docs/reference/resource/definition.md +10 -1
  21. data/docs/reference/resource/model.md +26 -0
  22. data/docs/reference/ui/forms.md +41 -0
  23. data/docs/reference/wizard/dsl.md +5 -0
  24. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  25. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  26. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  27. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  28. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  29. data/lib/plutonium/action/base.rb +8 -0
  30. data/lib/plutonium/configuration.rb +12 -0
  31. data/lib/plutonium/definition/index_views.rb +16 -0
  32. data/lib/plutonium/kanban/column.rb +80 -27
  33. data/lib/plutonium/models/has_cents.rb +30 -2
  34. data/lib/plutonium/resource/controller.rb +22 -1
  35. data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
  36. data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
  37. data/lib/plutonium/resource/policy.rb +6 -0
  38. data/lib/plutonium/routing/mapper_extensions.rb +1 -0
  39. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  40. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  41. data/lib/plutonium/ui/form/base.rb +6 -0
  42. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  43. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  44. data/lib/plutonium/ui/form/components/uppy.rb +20 -2
  45. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  46. data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
  47. data/lib/plutonium/ui/form/resource.rb +12 -0
  48. data/lib/plutonium/ui/form/theme.rb +7 -0
  49. data/lib/plutonium/ui/grid/card.rb +40 -13
  50. data/lib/plutonium/ui/kanban/column.rb +111 -24
  51. data/lib/plutonium/ui/kanban/resource.rb +118 -11
  52. data/lib/plutonium/ui/layout/base.rb +1 -1
  53. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  54. data/lib/plutonium/ui/page/index.rb +1 -1
  55. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  56. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  57. data/lib/plutonium/ui/page/show.rb +7 -2
  58. data/lib/plutonium/ui/table/resource.rb +1 -1
  59. data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
  60. data/lib/plutonium/version.rb +1 -1
  61. data/package.json +5 -3
  62. data/src/css/components.css +5 -0
  63. data/src/js/controllers/currency_input_controller.js +39 -0
  64. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  65. data/src/js/controllers/kanban_controller.js +442 -55
  66. data/src/js/controllers/register_controllers.js +2 -0
  67. data/yarn.lock +674 -4
  68. metadata +14 -2
@@ -53,7 +53,19 @@ module Plutonium
53
53
  # Resolved by the controller and threaded through to Kanban::Card.
54
54
  # Defaults to "_top" so a card always escapes the column's lazy frame when
55
55
  # the component is built outside a controller (tests, board shell).
56
- def initialize(column:, cards:, total:, per_column:, resource_definition:, resource_fields:, column_action_data: [], column_add_url: nil, card_fields: nil, card_show_frame: "_top")
56
+ # collapsed: the EFFECTIVE collapse state for this render (the user's
57
+ # cookie-persisted choice resolved against the column default by the
58
+ # controller). nil → fall back to the column's declared default. The
59
+ # component still emits the DEFAULT separately (data-kanban-default-
60
+ # collapsed) so the controller can store only deltas from it.
61
+ # drop_form_url_template: the kanban_move_form member URL with an __ID__
62
+ # placeholder for the dragged card's record id (mirrors the board's
63
+ # move-url template). Set by the controller ONLY for columns that declare
64
+ # an enter_interaction:; nil for plain columns. When present, the wrapper
65
+ # advertises data-kanban-drop-interaction + data-kanban-drop-form-url-
66
+ # template so Task 6's Stimulus controller opens the interaction modal on
67
+ # drop instead of committing the move immediately.
68
+ def initialize(column:, cards:, total:, per_column:, resource_definition:, resource_fields:, column_action_data: [], column_add_url: nil, board_url: nil, card_fields: nil, card_show_frame: "_top", collapsed: nil, drop_form_url_template: nil, drop_immediate: false, drop_confirm: nil)
57
69
  @column = column
58
70
  @cards = cards
59
71
  @total = total
@@ -62,8 +74,19 @@ module Plutonium
62
74
  @resource_fields = resource_fields
63
75
  @column_action_data = column_action_data
64
76
  @column_add_url = column_add_url
77
+ @board_url = board_url
65
78
  @card_fields = card_fields
66
79
  @card_show_frame = card_show_frame
80
+ @collapsed = collapsed
81
+ @drop_form_url_template = drop_form_url_template
82
+ @drop_immediate = drop_immediate
83
+ @drop_confirm = drop_confirm
84
+ end
85
+
86
+ # Effective collapse state: the caller's resolved value, or the column
87
+ # default when none was passed (tests / board shell / non-cookie paths).
88
+ def effective_collapsed?
89
+ @collapsed.nil? ? column.collapsed? : @collapsed
67
90
  end
68
91
 
69
92
  def view_template
@@ -73,13 +96,9 @@ module Plutonium
73
96
  div(
74
97
  class: tokens(
75
98
  "pu-kanban-column-wrapper",
76
- column.collapsed? && "pu-kanban-column-collapsed"
99
+ effective_collapsed? && "pu-kanban-column-collapsed"
77
100
  ),
78
- data: {
79
- kanban_col: column.key.to_s,
80
- kanban_accepts: accepts_value,
81
- kanban_locked: column.locked?.to_s
82
- }
101
+ data: wrapper_data
83
102
  ) do
84
103
  render_collapsed_strip
85
104
  render_expanded
@@ -88,6 +107,33 @@ module Plutonium
88
107
 
89
108
  private
90
109
 
110
+ # Data attributes for the [data-kanban-col] wrapper. Drop-policy hints
111
+ # (accepts/locked) and the collapse default are always present; the
112
+ # drop-interaction attributes are added only when the column declares an
113
+ # enter_interaction: (advertised via the controller-computed template).
114
+ def wrapper_data
115
+ data = {
116
+ kanban_col: column.key.to_s,
117
+ # The DEFAULT (not the effective state) so the controller can store
118
+ # only a delta from it in the cookie.
119
+ kanban_default_collapsed: column.collapsed?.to_s,
120
+ kanban_accepts: accepts_value,
121
+ kanban_locked: column.locked?.to_s
122
+ }
123
+
124
+ if column.enter_interaction?
125
+ data[:kanban_drop_interaction] = "true"
126
+ data[:kanban_drop_form_url_template] = @drop_form_url_template
127
+ # An input-less interaction commits directly on drop (no form modal);
128
+ # the client reads this to skip opening an empty modal. The optional
129
+ # confirm message (auto "<label>?" for immediate actions) gates it.
130
+ data[:kanban_drop_immediate] = "true" if @drop_immediate
131
+ data[:kanban_drop_confirm] = @drop_confirm if @drop_confirm.present?
132
+ end
133
+
134
+ data
135
+ end
136
+
91
137
  # ---------------------------------------------------------------
92
138
  # Collapsed strip
93
139
  # ---------------------------------------------------------------
@@ -102,6 +148,9 @@ module Plutonium
102
148
  "rounded-[var(--pu-radius-md)] select-none",
103
149
  data: {kanban_role: "strip"}
104
150
  ) do
151
+ # The color dot is the outcome signal for terminal columns (:done
152
+ # green, :lost red) — keep it visible when collapsed too.
153
+ render_color_dot(column.color) if column.color
105
154
  span(
106
155
  class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
107
156
  "[writing-mode:vertical-lr] rotate-180"
@@ -120,7 +169,9 @@ module Plutonium
120
169
  kanban_column_key: column.key.to_s
121
170
  }
122
171
  ) { plain "▶" }
123
- render_column_actions if column.actions.any?
172
+ # No column actions here: a collapsed column is a thin strip, so its
173
+ # bulk actions (and "+ Add") stay hidden until it's expanded. They
174
+ # live only in the expanded header (render_header).
124
175
  end
125
176
  end
126
177
 
@@ -147,7 +198,13 @@ module Plutonium
147
198
  div(class: "flex items-center gap-2 min-w-0 flex-1") do
148
199
  render_color_dot(column.color) if column.color
149
200
  span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
150
- render_wip_badge if column.wip
201
+ # Always show a card count (matching the collapsed strip); when a
202
+ # WIP limit is set it becomes the "count/limit" badge instead.
203
+ if column.wip
204
+ render_wip_badge
205
+ else
206
+ render_count_badge
207
+ end
151
208
  end
152
209
  render_column_actions if @column_add_url || column.actions.any?
153
210
  # Collapse toggle — always present in the expanded header so the
@@ -167,6 +224,12 @@ module Plutonium
167
224
  end
168
225
  end
169
226
 
227
+ # Plain card-count badge for columns without a WIP limit — mirrors the
228
+ # count shown on the collapsed strip so the number is visible either way.
229
+ def render_count_badge
230
+ span(class: "pu-badge pu-badge-neutral text-xs font-mono") { plain cards.size.to_s }
231
+ end
232
+
170
233
  def render_wip_badge
171
234
  over = wip_over_limit?
172
235
  span(
@@ -243,15 +306,17 @@ module Plutonium
243
306
  # Renders bulk-action links for each column action, and the "+ Add"
244
307
  # quick-add button when column_add_url is present.
245
308
  #
246
- # Each bulk-action link targets GET /resources/bulk_actions/:key?ids[]=…,
247
- # which is the existing interactive_bulk_action route. The action is only
248
- # rendered when:
249
- # 1. The resolved action is registered in defined_actions (auto-registered
250
- # by Definition::IndexViews.kanban at class-load time).
251
- # 2. current_policy.allowed_to?(:"#{key}?") returns true.
309
+ # Each link targets /resources/bulk_actions/:key?ids[]=… — the same path
310
+ # for the GET form and the POST commit. The render mirrors ActionButton so
311
+ # the action's shape is honoured (see #column_action_link_data):
312
+ # an interaction with NO user inputs is `immediate` POST + confirm,
313
+ # executed directly instead of opening an empty form modal;
314
+ # one with inputs opens the interaction form in the remote modal.
252
315
  #
253
- # The bulk endpoint re-authorizes each record individually, so this
254
- # check is a display-only gate not the security boundary.
316
+ # The action is only rendered when it's registered in defined_actions
317
+ # (auto-registered by Definition::IndexViews.kanban) and permitted by the
318
+ # policy. The bulk endpoint re-authorizes each record, so this is a
319
+ # display-only gate, not the security boundary.
255
320
  def render_column_actions
256
321
  div(class: "flex items-center gap-1 shrink-0") do
257
322
  render_add_button if @column_add_url
@@ -268,19 +333,18 @@ module Plutonium
268
333
  # if clicked. An empty column simply renders no action link.
269
334
  next if ids.empty?
270
335
 
271
- url = resource_url_for(resource_class, interaction: col_action.key, ids: ids)
336
+ # return_to the board so the action redirects back to it (not the
337
+ # table index) AND the board refreshes: the board-bound redirect is
338
+ # tagged with kanban_reload (KanbanActions#kanban_reload_url), so the
339
+ # column re-fetches and the mutated cards (e.g. archived) update.
340
+ url = resource_url_for(resource_class, interaction: col_action.key, ids: ids, return_to: @board_url)
272
341
  label = col_action.label || col_action.key.to_s.humanize
273
- data_attrs = {
274
- kanban_action: col_action.key.to_s,
275
- kanban_column: column.key.to_s
276
- }
277
- data_attrs[:turbo_confirm] = col_action.confirmation if col_action.confirmation
278
342
 
279
343
  link_to(
280
344
  url,
281
345
  class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
282
346
  title: label,
283
- data: data_attrs
347
+ data: column_action_link_data(col_action, registered)
284
348
  ) do
285
349
  render col_action.icon.new(class: "h-4 w-4") if col_action.icon
286
350
  plain label
@@ -289,6 +353,29 @@ module Plutonium
289
353
  end
290
354
  end
291
355
 
356
+ # Data attributes for a column-action link, honouring the interaction's
357
+ # shape. `immediate` actions (no inputs) POST straight to the commit route
358
+ # with a confirmation, executed directly; the rest open the form in the
359
+ # remote modal. The confirmation prefers the DSL-supplied one, falling
360
+ # back to the action's own default ("<label>?" for immediate actions).
361
+ def column_action_link_data(col_action, registered)
362
+ data = {
363
+ kanban_action: col_action.key.to_s,
364
+ kanban_column: column.key.to_s
365
+ }
366
+
367
+ if registered.immediate
368
+ data[:turbo_method] = :post
369
+ confirmation = col_action.confirmation || registered.confirmation
370
+ data[:turbo_confirm] = confirmation if confirmation.present?
371
+ else
372
+ data[:turbo_frame] = Plutonium::REMOTE_MODAL_FRAME
373
+ data[:turbo_confirm] = col_action.confirmation if col_action.confirmation
374
+ end
375
+
376
+ data
377
+ end
378
+
292
379
  # ---------------------------------------------------------------
293
380
  # Pure helpers
294
381
  # ---------------------------------------------------------------
@@ -24,6 +24,28 @@ module Plutonium
24
24
  include Phlex::Rails::Helpers::TurboFrameTag
25
25
  include Phlex::Rails::Helpers::TurboStreamFrom
26
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
+
27
49
  attr_reader :board, :grouped_data, :resource_definition, :resource_fields,
28
50
  :resource_class, :scoped_entity
29
51
 
@@ -64,18 +86,31 @@ module Plutonium
64
86
  render_toolbar
65
87
 
66
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}",
67
97
  class: "pu-kanban-board flex gap-4 overflow-x-auto p-4 min-h-0",
68
98
  data: {
99
+ turbo_permanent: true,
69
100
  controller: "kanban",
70
101
  # Stimulus value consumed by the drag controller to build the
71
102
  # per-record move URL at drop time. The collection path comes from
72
103
  # request.path so tenant / engine scoping is preserved automatically.
73
104
  # Example: /admin/tasks/__ID__/kanban_move
74
- kanban_move_url_template_value: kanban_move_url_template
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)
75
110
  }
76
111
  ) do
77
112
  grouped_data.each do |entry|
78
- render_column_frame(entry[:column])
113
+ render_column_frame(entry[:column], collapsed: entry[:collapsed])
79
114
  end
80
115
  end
81
116
 
@@ -157,29 +192,101 @@ module Plutonium
157
192
  )
158
193
  end
159
194
 
160
- def render_column_frame(column)
161
- attrs = {src: column_frame_src(column)}
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
+ }
162
205
  attrs[:loading] = "lazy" if board.lazy?
163
206
 
164
207
  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.
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
168
225
  render_column_header(column)
169
226
  end
170
227
  end
171
228
 
172
- # Mirrors Kanban::Column#render_header structurally (no count badge) so
173
- # the shell→loaded transition doesn't flash a stale count or restructure.
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.
174
258
  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
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
177
261
  render_color_dot(column.color) if column.color
178
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")
179
267
  end
268
+ render_icon_placeholder
180
269
  end
181
270
  end
182
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
+
183
290
  # Builds the move URL template for the Stimulus drag controller.
184
291
  # The collection path from the current request is used so engine
185
292
  # mounting and path-scoped tenancy are automatically preserved.
@@ -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
 
@@ -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,7 +33,7 @@ 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
@@ -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,7 +15,7 @@ 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
@@ -48,7 +48,12 @@ module Plutonium
48
48
  size: :lg,
49
49
  open_full_url: request.path
50
50
  ) do
51
- render partial("resource_details")
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
52
57
  end
53
58
  end
54
59
 
@@ -151,7 +151,7 @@ module Plutonium
151
151
  policy = policy_for(record:)
152
152
 
153
153
  actions = resource_definition.defined_actions
154
- .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
154
+ .select { |k, a| a.collection_record_action? && !a.kanban_drop? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
155
155
  .values
156
156
 
157
157
  primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
@@ -33,11 +33,44 @@ module Plutonium
33
33
  input_options = @summary_inputs[name]&.dig(:options) || {}
34
34
  field_options = input_options[:label] ? {label: input_options[:label]} : {}
35
35
 
36
+ # A currency input stages a plain decimal; the data snapshot carries no
37
+ # has_cents reflection, so inference would render it as a bare number.
38
+ # Force the currency display and thread the declared `unit:` so the
39
+ # recap reads "$1,234.56", matching the input's prefix.
40
+ if input_options[:as]&.to_sym == :currency
41
+ return render field(name, **field_options).wrapped { |f|
42
+ render f.send(:create_component, Plutonium::UI::Display::Components::Currency, :currency, unit: input_options[:unit])
43
+ }
44
+ end
45
+
46
+ # A choice input (select/radio_buttons) stages the raw value; its
47
+ # value -> label map lives only in `choices:`. Resolve it and render the
48
+ # label as a string, rather than inferring a plain-string tag off the
49
+ # raw value (which would show "female" instead of "Female").
50
+ if input_options[:choices]
51
+ label = resolve_choice_label(name, input_options)
52
+ return render field(name, value: label, **field_options).wrapped { |f| render f.string_tag }
53
+ end
54
+
36
55
  render field(name, **field_options).wrapped do |f|
37
56
  render instance_exec(f, &summary_tag_block(name))
38
57
  end
39
58
  end
40
59
 
60
+ # Map the raw value(s) to their choice label(s) using the SAME mapper the
61
+ # form's select uses, so summary labels always match the form. Falls back
62
+ # to the raw value for anything not in `choices:`; nil for an empty field
63
+ # (so the display renders its placeholder).
64
+ def resolve_choice_label(name, input_options)
65
+ mapper = Phlexi::Form::SimpleChoicesMapper.new(
66
+ input_options[:choices],
67
+ label_method: input_options[:label_method],
68
+ value_method: input_options[:value_method]
69
+ )
70
+ raw = Phlexi::Field::Support::Value.from(object, name)
71
+ Array(raw).map { |value| mapper[value] || value }.join(", ").presence
72
+ end
73
+
41
74
  # Pick the display component the same way a resource display does — infer it
42
75
  # from the value's TYPE (date, boolean, number, currency, …) instead of
43
76
  # stringifying everything. The one override: an attachment field stages a
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.61.0"
2
+ VERSION = "0.62.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -49,6 +49,7 @@
49
49
  "postcss-import": "^16.1.1",
50
50
  "tailwindcss": "^4.3.0",
51
51
  "vitepress": "^1.6.4",
52
+ "vitepress-plugin-llms": "^1.13.2",
52
53
  "vitepress-plugin-mermaid": "^2.0.17"
53
54
  },
54
55
  "scripts": {
@@ -59,8 +60,9 @@
59
60
  "js:dev": "node esbuild.config.js --dev",
60
61
  "css:prod": "postcss src/css/plutonium.entry.css -o app/assets/plutonium.css && postcss src/css/plutonium.entry.css -o src/dist/css/plutonium.css",
61
62
  "js:prod": "node esbuild.config.js",
62
- "docs:dev": "vitepress dev docs",
63
- "docs:build": "vitepress build docs",
63
+ "docs:sync-skills": "node docs/.vitepress/sync-skills.mjs",
64
+ "docs:dev": "yarn docs:sync-skills && vitepress dev docs",
65
+ "docs:build": "yarn docs:sync-skills && vitepress build docs",
64
66
  "docs:preview": "vitepress preview docs"
65
67
  }
66
68
  }
@@ -635,6 +635,11 @@ select.pu-input[multiple] option {
635
635
  own pinned-right surface and intentionally does not opt in. */
636
636
  .pu-dialog {
637
637
  background-color: var(--pu-surface);
638
+ /* Pair the surface background with the theme text colour. The UA stylesheet
639
+ resets <dialog> color to CanvasText (black) and it does NOT inherit from
640
+ <body>, so without this any unstyled text inside a modal (e.g. a show page's
641
+ plain field values) renders black on the dark surface. */
642
+ color: var(--pu-text);
638
643
  border: 1px solid var(--pu-border);
639
644
  border-radius: var(--pu-radius-lg);
640
645
  }
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="currency-input"
4
+ //
5
+ // Pads a currency input's left edge to exactly clear its overlaid unit prefix.
6
+ //
7
+ // The prefix ("$", "£", "GH₵", "USD", …) is absolutely positioned at the
8
+ // field's left edge; its width varies by symbol AND font, so any fixed padding
9
+ // is wrong for some currency — too little clips a wide prefix (digits collide
10
+ // with it, as with "GH₵"), too much wastes space on a bare "$". This measures
11
+ // the rendered prefix and sets padding-left to match, so the caret always sits
12
+ // just past the symbol regardless of currency or typeface.
13
+ //
14
+ // DOM contract:
15
+ // wrapper data-controller="currency-input"
16
+ // prefix data-currency-input-target="prefix" (the overlaid unit span)
17
+ // input data-currency-input-target="field" (the number input)
18
+ export default class extends Controller {
19
+ static targets = ["prefix", "field"]
20
+ // Space between the prefix and the first digit, in px.
21
+ static values = { gap: { type: Number, default: 6 } }
22
+
23
+ connect() {
24
+ this.#pad()
25
+ // Web fonts often load after connect; the fallback font's metrics differ,
26
+ // so re-measure once they're ready to correct the padding.
27
+ document.fonts.ready.then(() => this.#pad())
28
+ }
29
+
30
+ #pad() {
31
+ // Inline !important so it beats pu-input's own left padding (a plain class
32
+ // would lose the cascade — see the note this replaces in currency.rb).
33
+ this.fieldTarget.style.setProperty(
34
+ "padding-left",
35
+ `${this.prefixTarget.offsetWidth + this.gapValue}px`,
36
+ "important"
37
+ )
38
+ }
39
+ }