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
@@ -90,7 +90,7 @@ Plutonium still orders column cards by `sort_order` (the first argument); your b
90
90
  position_on false
91
91
  ```
92
92
 
93
- No ordering is applied. Cards render in the relation's natural order. On drop, only `on_drop` fires (if set); the position attribute is never touched.
93
+ No ordering is applied. Cards render in the relation's natural order. On drop, only `on_enter` fires (if set); the position attribute is never touched.
94
94
 
95
95
  ---
96
96
 
@@ -165,6 +165,8 @@ Overrides the slot layout for every kanban card on this board, using the same sl
165
165
 
166
166
  When `card_fields` is not set, cards fall back to the resource definition's `grid_fields`. If neither is declared, the card renders the default header-only layout.
167
167
 
168
+ The `meta` slot renders each field as a colored badge, and formats values by type before badging: a `has_cents` field renders as currency, a `belongs_to` association renders as its label (not an object inspect), and everything else is humanized — with status-like enums (`active`, `pending`, `published`…) resolving to a semantic color. The badge color is deterministic per value, so a given status is the same color on every card.
169
+
168
170
  ---
169
171
 
170
172
  ## Static columns
@@ -177,12 +179,12 @@ kanban do
177
179
  label: "To Do",
178
180
  color: :blue,
179
181
  scope: -> { where(status: "todo") },
180
- on_drop: ->(r) { r.update!(status: "todo") },
182
+ on_enter: ->(r) { r.update!(status: "todo") },
181
183
  role: :backlog
182
184
 
183
185
  column :done,
184
186
  scope: -> { where(status: "done") },
185
- on_drop: :mark_done!,
187
+ on_enter: :mark_done!,
186
188
  accepts: [:doing],
187
189
  role: :done do
188
190
  action :archive_all, interaction: ArchiveTasksInteraction, on: :all
@@ -197,23 +199,60 @@ end
197
199
  | `label:` | String | `key.to_s.titleize` | Column header label |
198
200
  | `color:` | Symbol or String | `nil` | Header color dot. Named colors: `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`. Raw CSS string also accepted |
199
201
  | `scope:` | Symbol or Proc | `nil` | Relation filter for this column. **Symbol** → `relation.public_send(sym)` (named AR scope). **Proc** → 0-arg lambda called via `instance_exec` on the relation, e.g. `-> { where(status: "todo") }` |
200
- | `on_drop:` | Symbol or Proc | `nil` | Fired when a card is dropped into this column. **Symbol** → `record.public_send(sym)`. **Proc** → 1-arg lambda `->(record) { … }` where `self` inside the block is the view context (giving access to `current_user`, helpers, etc.). The callback may assign attributes in memory (`r.status = :done`) or call `update!` directly; if the record has unsaved changes after `on_drop` returns the controller saves it automatically. |
201
- | `role:` | `:backlog`, `:done` | `nil` | Applies a preset (see below) |
202
+ | `on_enter:` | Symbol or Proc | `nil` | Fired when a card is dropped into this column. **Symbol** → `record.public_send(sym)`. **Proc** → 1-arg lambda `->(record) { … }` where `self` inside the block is the view context (giving access to `current_user`, helpers, etc.). The callback may assign attributes in memory (`r.status = :done`) or call `update!` directly; if the record has unsaved changes after `on_enter` returns the controller saves it automatically. |
203
+ | `on_exit:` | Symbol or Proc | `nil` | The source-side counterpart to `on_enter:`, fired when a card **leaves** this column on a cross-column move. Same dispatch (**Symbol** → `record.public_send`; **Proc** → 1-arg lambda, `self` = view context). Runs **before** the destination's `on_enter`, inside the same move transaction, so it sees the pre-move state and rolls back if the move fails. Use it for source-tied side effects the destination can't own (stop a timer, release a slot). Fires only on a drag-move through `kanban_move` — not on destroy, a programmatic status change, or quick-add. Skipped on same-column reorders. |
204
+ | `enter_interaction:` | Class | `nil` | A **record-scoped** interaction class (declares `attribute :resource`) run when a card is dropped **into** this column from another column. Opens the interaction's form as a modal to collect input, then commits `on_enter` + the interaction + repositioning atomically. Auto-registered as a hidden record action under a column-scoped key (`:<column>_enter_interaction`), authorized by `kanban_move?` (no policy method of its own). See [enter_interaction](#drop-interaction) below |
205
+ | `role:` | `:backlog`, `:done`, `:lost` | `nil` | Applies a preset (see below) |
202
206
  | `collapsed:` | Boolean | `false` | Column starts collapsed (a thin strip with the label rotated). The Stimulus controller persists the toggled state to `localStorage` (key: `pu-kanban:<path>:<column-key>:collapsed`) so the user preference survives page reloads; this DSL value sets the server-rendered initial state only. |
203
207
  | `add:` | Boolean | `false` | Show a `+ Add` quick-add button |
204
- | `accepts:` | `true`, `false`, Array, or Proc | `true` | Drop policy. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. A 1-arg Proc `->(record) { }` is evaluated **per-card on the server** at drop time (via `accepts_record?`) and returns a boolean — e.g. `->(task) { task.status == "doing" }`. The client-side drag hint treats a Proc column as permissive (`data-kanban-accepts="all"`) since the browser can't run the Proc; the server enforces it precisely on every move |
208
+ | `accepts:` | `true`, `false`, or Array | `true` | Drop policy — **structural topology only**. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. This is client-hintable: the value is emitted as `data-kanban-accepts` so the drag UI greys out disallowed source columns before the drop. **Record- or user-conditional rules do not belong here** put them in `kanban_move?`, which sees the record and the `from`/`to` columns (see [Authorization](./authorization)). Passing a `Proc` raises `ArgumentError`. |
205
209
  | `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
206
210
  | `wip:` | Integer | `nil` | WIP limit. Reject cross-column drops when `dest_count + 1 > wip`. Has no effect on same-column reordering |
207
211
 
212
+ ::: warning Renamed: `on_drop:` → `on_enter:`, `drop_interaction:` → `enter_interaction:`
213
+ The older `on_drop:` and `drop_interaction:` option names have been **renamed** to `on_enter:` and `enter_interaction:` (a card *entering* a column is the event, independent of how it got there).
214
+
215
+ The old names are **deprecated aliases**:
216
+ - In **development and test** they raise an `ArgumentError` so the rename is caught before release.
217
+ - In **deployed environments** (production, staging) they log a deprecation warning and map onto the new option, so an in-flight deployment keeps working across the upgrade.
218
+
219
+ If both the old and new name are given, the new one wins. Update your definitions to `on_enter:` / `enter_interaction:` at your earliest convenience — a future release will drop the aliases entirely.
220
+ :::
221
+
208
222
  ### Role presets
209
223
 
210
224
  | Role | Equivalent options |
211
225
  |------|--------------------|
212
226
  | `:backlog` | `add: true` |
213
227
  | `:done` | `color: :green, collapsed: true` |
228
+ | `:lost` | `color: :red, collapsed: true` |
229
+
230
+ `:done` and `:lost` are the two terminal roles — both collapsed by default, the
231
+ colour signalling the outcome (`:done` = positive close, `:lost` = negative
232
+ close). Use them as the won/lost pair in pipelines (leads, deals, tickets).
214
233
 
215
234
  Explicitly passed options override the preset. Unknown role values raise `ArgumentError`.
216
235
 
236
+ ### `enter_interaction:` {#drop-interaction}
237
+
238
+ ```ruby
239
+ column :lost,
240
+ scope: -> { where(status: "lost") },
241
+ enter_interaction: MarkLostInteraction
242
+ ```
243
+
244
+ Runs an authorization-aware, input-collecting interaction when a card is dropped **into** this column from another column.
245
+
246
+ - **Must be a record-scoped interaction** — the class declares `attribute :resource` (singular) and acts on the one dropped card. A `resources`-plural (bulk) interaction is not valid here; that shape is for [column actions](#column-actions).
247
+ - **Auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`) — unique by construction, so two columns can reuse the same interaction class without colliding. Hidden = it does not render as an action button on the show page, table rows, or grid cards; it is reachable only by a drop.
248
+ - **Authorized by `kanban_move?` alone** — the interaction has **no policy method of its own**. The move (and therefore the interaction) is gated by the single `kanban_move?` predicate, which can gate this specific transition via its `to` column context (e.g. `def kanban_move? = kanban_to&.key == :lost ? user.manager? : super`). See [Authorization](./authorization).
249
+ - **Move flow only.** Dropping cross-column opens the interaction's form as a modal; on submit `on_enter` + the interaction + repositioning commit in **one atomic transaction**. Validation failure rolls the whole transaction back (membership write included) and re-renders the modal with errors — nothing persists. Same-column reorders run positioning only (neither `on_enter` nor the interaction fires).
250
+ - **Quick-add (`+ Add`)** applies `on_enter` + positioning **post-create** — the record is created first (needs a grouping-column default), then `on_enter` places it in the column; the `enter_interaction` is not involved.
251
+ - **Author contract** — when a column declares both, `on_enter` owns the membership attribute (e.g. `status`) and the interaction owns the extras (reason, mail, audit). If the interaction also writes the membership attribute it must set the same value `on_enter` does (idempotent). With no `on_enter`, the interaction owns everything.
252
+ - **Success response limitation** — the interaction's success **message** (`.with_message`) surfaces as a toast, but a custom success *response* (`with_redirect_response`, `with_file_response`, …) is **not** honored on the drop path; the board just re-renders and closes the modal.
253
+
254
+ See the [guide's Interaction on drop section](/guides/kanban#interaction-on-drop) for a full worked example.
255
+
217
256
  ---
218
257
 
219
258
  ## Dynamic columns
@@ -230,7 +269,7 @@ kanban do
230
269
  label: status.name,
231
270
  color: status.color_symbol,
232
271
  scope: -> { where(status_id: status.id) },
233
- on_drop: ->(r) { r.update!(status_id: status.id) }
272
+ on_enter: ->(r) { r.update!(status_id: status.id) }
234
273
  )
235
274
  end
236
275
  end
@@ -256,6 +295,8 @@ class TaskDefinition < ResourceDefinition
256
295
  end
257
296
  end
258
297
  ```
298
+
299
+ **`enter_interaction:` is not supported on dynamic boards.** Unlike a column action, its hidden action can't be registered manually — the key is column-scoped and internal — and the static registration pass has no columns to see. A drop into a dynamic column that declares `enter_interaction:` is rejected with a snap-back (it does not crash). Use a static board when a column needs an `enter_interaction:`.
259
300
  :::
260
301
 
261
302
  ---
@@ -267,7 +308,7 @@ Declare actions inside a column `do…end` block:
267
308
  ```ruby
268
309
  column :done,
269
310
  scope: -> { where(status: "done") },
270
- on_drop: :mark_done! do
311
+ on_enter: :mark_done! do
271
312
 
272
313
  action :archive_all,
273
314
  interaction: ArchiveTasksInteraction,
@@ -20,17 +20,17 @@ class TaskDefinition < ResourceDefinition
20
20
 
21
21
  column :todo,
22
22
  scope: -> { where(status: "todo") },
23
- on_drop: ->(r) { r.update!(status: "todo") },
23
+ on_enter: ->(r) { r.update!(status: "todo") },
24
24
  role: :backlog
25
25
 
26
26
  column :doing,
27
27
  scope: -> { where(status: "doing") },
28
- on_drop: ->(r) { r.update!(status: "doing") },
28
+ on_enter: ->(r) { r.update!(status: "doing") },
29
29
  wip: 3
30
30
 
31
31
  column :done,
32
32
  scope: -> { where(status: "done") },
33
- on_drop: :mark_done!,
33
+ on_enter: :mark_done!,
34
34
  accepts: [:doing],
35
35
  role: :done
36
36
  end
@@ -140,7 +140,7 @@ kanban do
140
140
  end
141
141
  ```
142
142
 
143
- No ordering is applied (relation is returned unchanged). On drop, `on_drop` still fires; the position attribute is never touched. Cards render in the relation's default order.
143
+ No ordering is applied (relation is returned unchanged). On drop, `on_exit`/`on_enter` still fire; the position attribute is never touched. Cards render in the relation's default order.
144
144
 
145
145
  ---
146
146
 
@@ -121,7 +121,7 @@ These render automatically — declare an `as:` only to override or pass options
121
121
  |---|---|---|
122
122
  | `boolean` | Yes/No pill (`:boolean`) | green "Yes" / neutral "No"; override with `true_label:` / `false_label:` |
123
123
  | `enum` | status badge (`:badge`) | known statuses auto-colored; unknown values get a stable decorative color; override per-value with `colors:` |
124
- | `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals, **no symbol** unless you pass `unit:` (a literal `"£"` or a Symbol read off the record) |
124
+ | `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals; symbol from `unit:` on `has_cents` (model-wide) or per-display, else `config.default_currency_unit` / the i18n default (see below) |
125
125
 
126
126
  ```ruby
127
127
  display :status, as: :badge, colors: {archived: :neutral, vip: :accent}
@@ -129,6 +129,15 @@ display :price, as: :currency, unit: "£"
129
129
  display :active, as: :boolean, true_label: "Live", false_label: "Off"
130
130
  ```
131
131
 
132
+ **Currency symbol.** The `unit:` can be set on the model's `has_cents` declaration
133
+ (`has_cents :price_cents, unit: "£"`, or `unit: :currency_symbol` to read a method
134
+ off the record for per-row currencies). That model-level unit is used everywhere the
135
+ value renders as currency — the show page, tables, **and grid/kanban cards**. A
136
+ per-display `unit:` overrides it for that one display; `unit: false` explicitly
137
+ renders no symbol. When neither is set, currency falls back to
138
+ `Plutonium.configuration.default_currency_unit` (default: the i18n
139
+ `number.currency.format.unit` if the locale defines it — `$` in `en` — else no symbol).
140
+
132
141
  ## Field options
133
142
 
134
143
  ```ruby
@@ -97,6 +97,9 @@ class Product < ResourceRecord
97
97
  has_cents :cost_cents, name: :wholesale # custom accessor name
98
98
  has_cents :tax_cents, rate: 1000 # 3 decimal places (e.g. for fractional currencies)
99
99
  has_cents :amount_yen, rate: 1 # currencies with no subunit (JPY)
100
+ has_cents :gbp_cents, unit: "£" # currency symbol used wherever this renders as currency
101
+ has_cents :multi_cents, unit: :currency_symbol # per-row: reads record.currency_symbol
102
+ has_cents :points_cents, unit: false # explicitly no symbol (skips the default)
100
103
  end
101
104
 
102
105
  product = Product.new
@@ -109,6 +112,29 @@ product.price = 10.999
109
112
  product.price_cents # => 1099
110
113
  ```
111
114
 
115
+ **Currency symbol (`unit:`)** — a `String` is used verbatim (`unit: "£"`); a `Symbol`
116
+ names a method read off the record for per-row currencies (`unit: :currency_symbol`
117
+ → `record.currency_symbol`); `false` explicitly renders no symbol. This unit is picked
118
+ up automatically anywhere the value renders as currency — show pages, tables, and
119
+ grid/kanban cards — so you configure it once on the model. A per-display
120
+ `display :price, as: :currency, unit: …` overrides it for that display.
121
+
122
+ A `has_cents` field also **infers the currency input** on forms: a bare `input :price`
123
+ renders the [currency input](../ui/forms#currency-fields) (number field + unit prefix),
124
+ no `as: :currency` needed — matching the display side.
125
+
126
+ Resolution is `display unit → has_cents unit → config default`, where `nil` means
127
+ "not set, keep looking" and `false` means "stop, no symbol". When nothing is set, it
128
+ falls back to `Plutonium.configuration.default_currency_unit`, which itself defaults
129
+ to the i18n `number.currency.format.unit` *if the locale defines it* (`$` in `en`),
130
+ else no symbol:
131
+
132
+ ```ruby
133
+ Plutonium.configure do |config|
134
+ config.default_currency_unit = "£" # app-wide default; false for no symbol; nil → i18n-if-set
135
+ end
136
+ ```
137
+
112
138
  ::: danger Use the virtual accessor in policies and definitions
113
139
  Reference `:price`, NOT `:price_cents`:
114
140
 
@@ -162,6 +162,7 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
162
162
  | `slim_select_tag` | Slim Select (enhanced dropdown) |
163
163
  | `flatpickr_tag` | Flatpickr date/time picker |
164
164
  | `phone_tag` / `int_tel_input_tag` | intl-tel-input phone field |
165
+ | `currency_tag` | Money input (number field + optional unit prefix) |
165
166
  | `uppy_tag` / `file_tag` | Uppy file upload |
166
167
  | `secure_association_tag` | Association with policy-checked options (inline `+` add, typeahead) |
167
168
  | `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
@@ -175,6 +176,46 @@ render field(:avatar).wrapped do |f|
175
176
  end
176
177
  ```
177
178
 
179
+ ### Phone fields (intl-tel-input) {#phone-fields}
180
+
181
+ `as: :phone` renders an [intl-tel-input](https://github.com/jackocnr/intl-tel-input) field. Forward library options two ways:
182
+
183
+ - `initial_country:` — a convenient shortcut for the library's `initialCountry` (ISO2, e.g. `"gh"`). This preselects a country so the widget doesn't show *"No country selected"* and a bare local number validates.
184
+ - `intl_options:` — any other library option, using the library's own camelCase names. Merged over the shortcut, so it wins on conflict.
185
+
186
+ ```ruby
187
+ input :phone, as: :phone, initial_country: "gh"
188
+ input :phone, as: :phone, intl_options: {separateDialCode: true, strictMode: false}
189
+ ```
190
+
191
+ When a field sets no country, it falls back to `Plutonium.configuration.default_phone_country` (an ISO2 code, or `nil` to leave it to the library):
192
+
193
+ ```ruby
194
+ Plutonium.configure do |config|
195
+ config.default_phone_country = "gh"
196
+ end
197
+ ```
198
+
199
+ The field defaults to `strictMode: true`; override it via `intl_options: {strictMode: false}`.
200
+
201
+ ### Currency fields {#currency-fields}
202
+
203
+ `as: :currency` renders a number input (`inputmode="decimal"`, `step="0.01"`) with an **optional** currency-unit prefix overlaid at its left edge. The unit resolves by the **same** chain as the currency *display* ([`Currency.resolve_unit`](../resource/model#has-cents)), so the form, show page, index, and wizard summary all show the same symbol:
204
+
205
+ **explicit `unit:` → the record's `has_cents` unit → `config.default_currency_unit` → the i18n `number.currency.format.unit`.**
206
+
207
+ ```ruby
208
+ input :price, as: :currency # unit from has_cents / config / i18n
209
+ input :price, as: :currency, unit: "£" # a literal symbol
210
+ input :price, as: :currency, unit: false # no prefix — a plain number input
211
+ ```
212
+
213
+ When nothing resolves (or `unit: false`), the prefix is omitted and it's an ordinary number input.
214
+
215
+ **`has_cents` fields infer it automatically** — just like the display. A bare `input :price` on a `has_cents` attribute renders the currency input with the unit read off `has_cents`; no `as: :currency` needed. Use the explicit `as: :currency` for a non-`has_cents` decimal, or to override the unit.
216
+
217
+ In a **wizard** step the data snapshot has no `has_cents` reflection, so there's nothing to infer from — declare `as: :currency` and pass `unit:` explicitly (`input :price, as: :currency, unit: "$"`); the review summary reads it back and formats the value as currency.
218
+
178
219
  ### Password & secret fields {#password-fields}
179
220
 
180
221
  `password_tag` renders a masking input that **never emits the stored value** into the DOM. A stored secret renders a fixed sentinel (masking both the value and its length); on submit:
@@ -204,6 +204,11 @@ What it renders depends on completion state and the `summary:` / block options:
204
204
  | `summary:` | Show the auto-summary of completed steps (default `true`). When `false`, the complete-state body is your block — or the built-in "ready to complete" panel if there's no block. The summary always renders in the incomplete state. |
205
205
  | `header:` | Show the step-header section (the label plus the "check everything over" prompt, which only appears when the summary is shown) above the body (default `true`). `false` drops it for a chromeless finish. |
206
206
 
207
+ The auto-summary renders each field through the display pipeline, honoring the input's declared `as:`/`label:`:
208
+
209
+ - a **choice input** (`select`/`radio_buttons` with `choices:`) resolves the stored value back to its label (`"pro"` → `"Pro"`) using the same choice mapper the form uses, so the recap matches what the user picked;
210
+ - an **`as: :currency`** input formats the value as currency (`"1500.5"` → `"$1,500.50"`) rather than echoing a bare decimal — pass `unit:` on the input (the data snapshot has no `has_cents` reflection to infer it from).
211
+
207
212
  ```ruby
208
213
  review label: "Review & submit" # auto-summary + gated finish
209
214