plutonium 0.53.1 → 0.55.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +55 -0
  4. data/.claude/skills/plutonium-ui/SKILL.md +2 -1
  5. data/CHANGELOG.md +38 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +40 -6
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +32 -32
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_flash_alerts.html.erb +8 -17
  12. data/app/views/plutonium/_flash_toasts.html.erb +9 -18
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/reference/resource/definition.md +110 -0
  16. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  17. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  18. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  19. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  20. data/lib/plutonium/definition/base.rb +1 -0
  21. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  22. data/lib/plutonium/engine/validator.rb +11 -4
  23. data/lib/plutonium/interaction/README.md +24 -78
  24. data/lib/plutonium/interaction/base.rb +10 -2
  25. data/lib/plutonium/resource/controller.rb +6 -1
  26. data/lib/plutonium/resource/controllers/interactive_actions.rb +10 -6
  27. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  28. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  29. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +3 -3
  30. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +178 -0
  31. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  32. data/lib/plutonium/ui/form/resource.rb +15 -5
  33. data/lib/plutonium/ui/form/theme.rb +14 -3
  34. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  35. data/lib/plutonium/version.rb +1 -1
  36. data/package.json +1 -1
  37. data/src/css/components.css +119 -5
  38. data/src/js/controllers/capture_url_controller.js +20 -7
  39. data/src/js/controllers/dirty_form_guard_controller.js +28 -4
  40. data/src/js/controllers/icon_rail_flyout_controller.js +5 -2
  41. data/src/js/controllers/register_controllers.js +2 -0
  42. data/src/js/controllers/remote_modal_controller.js +5 -0
  43. data/src/js/controllers/structured_input_row_controller.js +26 -0
  44. metadata +14 -4
  45. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -10,7 +10,7 @@ module Plutonium
10
10
  module RendersNestedResourceFields
11
11
  extend ActiveSupport::Concern
12
12
 
13
- DEFAULT_NESTED_LIMIT = 10
13
+ DEFAULT_NESTED_LIMIT = RepeaterFieldStyles::DEFAULT_LIMIT
14
14
  NESTED_OPTION_KEYS = [:allow_destroy, :update_only, :macro, :class].freeze
15
15
  SINGULAR_MACROS = %i[belongs_to has_one].freeze
16
16
 
@@ -193,7 +193,7 @@ module Plutonium
193
193
  def render_nested_fields_fieldset(nested, context)
194
194
  fieldset(
195
195
  data_new_record: !nested.object&.persisted?,
196
- class: "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
196
+ class: RepeaterFieldStyles::FIELDSET_CLASS
197
197
  ) do
198
198
  render_nested_fields_fieldset_content(nested, context)
199
199
  render_nested_fields_delete_button(nested, context.options)
@@ -201,7 +201,7 @@ module Plutonium
201
201
  end
202
202
 
203
203
  def render_nested_fields_fieldset_content(nested, context)
204
- div(class: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense") do
204
+ div(class: RepeaterFieldStyles::FIELD_GRID_CLASS) do
205
205
  render_nested_fields_hidden_fields(nested, context)
206
206
  render_nested_fields_visible_fields(nested, context)
207
207
  end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Concerns
7
+ # Renders classless structured inputs (single → hash via nest_one,
8
+ # repeater → array via nest_many). Field/namespace work is delegated to
9
+ # the form; this concern owns the structural markup.
10
+ # @api private
11
+ module RendersStructuredInputs
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ def render_structured_input(name)
17
+ entry = resource_definition.defined_structured_inputs[name]
18
+ options = entry[:options] || {}
19
+ definition = structured_input_fields_definition(entry)
20
+ fields = options[:fields] || definition.defined_inputs.keys
21
+ repeat = options[:repeat]
22
+
23
+ if repeat
24
+ render_structured_repeater(name, definition, fields, repeat_limit(repeat))
25
+ else
26
+ render_structured_single(name, definition, fields)
27
+ end
28
+ end
29
+
30
+ def structured_input_fields_definition(entry)
31
+ return entry[:options][:using] if entry[:options]&.key?(:using)
32
+
33
+ holder = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
34
+ entry[:block].call(holder)
35
+ holder
36
+ end
37
+
38
+ def repeat_limit(repeat)
39
+ repeat.is_a?(Integer) ? repeat : RepeaterFieldStyles::DEFAULT_LIMIT
40
+ end
41
+
42
+ # --- single -------------------------------------------------------
43
+
44
+ def render_structured_single(name, definition, fields)
45
+ raw = structured_input_value(name)
46
+ value = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
47
+ div(class: "col-span-full space-y-2 my-4") do
48
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
49
+ nest_one(name, as: name, object: value) do |nested|
50
+ render_structured_fieldset(nested, definition, fields, removable: false)
51
+ end
52
+ end
53
+ end
54
+
55
+ # --- repeater -----------------------------------------------------
56
+
57
+ def render_structured_repeater(name, definition, fields, limit)
58
+ rows = Array(structured_input_value(name)).map { |row| row.is_a?(Hash) ? row.with_indifferent_access : row }
59
+ existing_collection = rows.presence || {NEW_RECORD: {}}
60
+ div(
61
+ class: "col-span-full space-y-2 my-4",
62
+ data: {
63
+ controller: "nested-resource-form-fields",
64
+ nested_resource_form_fields_limit_value: limit
65
+ }
66
+ ) do
67
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
68
+ template data_nested_resource_form_fields_target: "template" do
69
+ nest_many(name, as: name, collection: {NEW_RECORD: {}}, default: {NEW_RECORD: {}}, template: true) do |nested|
70
+ render_structured_fieldset(nested, definition, fields, removable: true)
71
+ end
72
+ end
73
+ nest_many(name, as: name, collection: existing_collection) do |nested|
74
+ if nested.object.blank?
75
+ vanish { render_structured_fieldset(nested, definition, fields, removable: true) }
76
+ else
77
+ render_structured_fieldset(nested, definition, fields, removable: true)
78
+ end
79
+ end
80
+ div(data_nested_resource_form_fields_target: :target, hidden: true)
81
+ render_structured_add_button(name)
82
+ end
83
+ end
84
+
85
+ # --- helpers ------------------------------------------------------
86
+
87
+ def structured_input_value(name)
88
+ obj = object
89
+ return nil unless obj.respond_to?(name)
90
+ obj.public_send(name)
91
+ end
92
+
93
+ # Single source of truth shared with RendersNestedResourceFields.
94
+ FIELDSET_CLASS = RepeaterFieldStyles::FIELDSET_CLASS
95
+ FIELD_GRID_CLASS = RepeaterFieldStyles::FIELD_GRID_CLASS
96
+
97
+ # @param removable [Boolean] repeater rows are removable; a single
98
+ # structured input is the one-and-only object, so it is not.
99
+ def render_structured_fieldset(nested, definition, fields, removable:)
100
+ unless removable
101
+ return fieldset(class: FIELDSET_CLASS) do
102
+ div(class: FIELD_GRID_CLASS) do
103
+ fields.each { |input| render_simple_resource_field(input, definition, nested) }
104
+ end
105
+ end
106
+ end
107
+
108
+ # Removable rows soft-delete by DISABLING the inner fieldset: a
109
+ # disabled <fieldset> is omitted from submission, so the server just
110
+ # receives the payload without this row and rebuilds the JSON column
111
+ # from what it gets (no _destroy marker). The row stays in the DOM,
112
+ # collapsed to a "Removed — Restore" bar, so it can be restored.
113
+ div(data_controller: "structured-input-row", class: FIELDSET_CLASS) do
114
+ fieldset(data_structured_input_row_target: "content", class: "space-y-4 border-0 p-0 m-0") do
115
+ div(class: FIELD_GRID_CLASS) do
116
+ fields.each { |input| render_simple_resource_field(input, definition, nested) }
117
+ end
118
+ render_structured_remove_button
119
+ end
120
+ render_structured_removed_bar
121
+ end
122
+ end
123
+
124
+ def render_structured_remove_button
125
+ div(class: "flex items-center justify-end") do
126
+ button(
127
+ type: :button,
128
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
129
+ "text-danger-700 hover:bg-danger-50 dark:text-danger-400 dark:hover:bg-danger-950/30 " \
130
+ "focus:outline-none focus:ring-4 focus:ring-danger-200 dark:focus:ring-danger-900",
131
+ data_action: "structured-input-row#remove"
132
+ ) do
133
+ render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
134
+ span { "Remove" }
135
+ end
136
+ end
137
+ end
138
+
139
+ # Compact bar shown in place of the row once it's marked for removal.
140
+ def render_structured_removed_bar
141
+ div(
142
+ data_structured_input_row_target: "removed",
143
+ hidden: true,
144
+ class: "flex items-center justify-between gap-3 text-sm text-[var(--pu-text-muted)]"
145
+ ) do
146
+ span(class: "italic") { "Removed" }
147
+ button(
148
+ type: :button,
149
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
150
+ "text-secondary-700 hover:bg-secondary-50 dark:text-secondary-300 dark:hover:bg-secondary-900/30 " \
151
+ "focus:outline-none focus:ring-4 focus:ring-secondary-200 dark:focus:ring-secondary-900",
152
+ data_action: "structured-input-row#restore"
153
+ ) do
154
+ render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
155
+ span { "Restore" }
156
+ end
157
+ end
158
+ end
159
+
160
+ def render_structured_add_button(name)
161
+ div do
162
+ button(
163
+ type: :button,
164
+ class: "inline-block",
165
+ data: {action: "nested-resource-form-fields#add", nested_resource_form_fields_target: "addButton"}
166
+ ) do
167
+ span(class: "bg-secondary-700 text-white flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg") do
168
+ render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
169
+ span { "Add #{name.to_s.singularize.humanize}" }
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Concerns
7
+ # Shared sizing + chrome for repeater-style field groups. Both
8
+ # RendersNestedResourceFields and RendersStructuredInputs render the
9
+ # same fieldset/grid markup and share a default row limit, so the
10
+ # values live here to keep the two concerns from drifting apart.
11
+ module RepeaterFieldStyles
12
+ # Default maximum number of rows a repeater renders/clones.
13
+ DEFAULT_LIMIT = 10
14
+
15
+ # Outer fieldset chrome for a single repeater row.
16
+ FIELDSET_CLASS = "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
17
+
18
+ # Responsive grid the row's fields are laid out in.
19
+ FIELD_GRID_CLASS = "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -5,6 +5,7 @@ module Plutonium
5
5
  module Form
6
6
  class Resource < Base
7
7
  include Plutonium::UI::Form::Concerns::RendersNestedResourceFields
8
+ include Plutonium::UI::Form::Concerns::RendersStructuredInputs
8
9
 
9
10
  attr_reader :resource_fields, :resource_definition, :singular_resource
10
11
 
@@ -104,11 +105,18 @@ module Plutonium
104
105
  end
105
106
 
106
107
  def render_actions
107
- # capture-url controller sets this element's value to
108
- # window.location.href on connect, so URL fragments (#tab-id)
109
- # survive the redirect after submit (the server never sees them).
108
+ # Only carry an *explicit* return_to. We deliberately do NOT fall
109
+ # back to request.original_url: for interactive-action forms that URL
110
+ # is the action's own (modal-only) path, and submitting it back would
111
+ # "return" to a bare standalone form — a blank page. When absent, the
112
+ # controller computes the right destination (redirect_url_after_submit
113
+ # / redirect_url_after_action_on, both → resource_url_for).
114
+ #
115
+ # capture-url grafts the live URL fragment (#tab-id) onto this value
116
+ # on connect (the server never sees fragments), but only when a base
117
+ # value is present.
110
118
  input name: "return_to",
111
- value: request.params[:return_to] || request.original_url,
119
+ value: request.params[:return_to],
112
120
  type: :hidden,
113
121
  hidden: true,
114
122
  data: {controller: "capture-url"}
@@ -164,7 +172,9 @@ module Plutonium
164
172
 
165
173
  def render_resource_field(name)
166
174
  when_permitted(name) do
167
- if resource_definition.respond_to?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
175
+ if resource_definition.respond_to?(:defined_structured_inputs) && resource_definition.defined_structured_inputs[name]
176
+ render_structured_input(name)
177
+ elsif resource_definition.respond_to?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
168
178
  render_nested_resource_field(name)
169
179
  else
170
180
  render_simple_resource_field(name, resource_definition, self)
@@ -19,7 +19,10 @@ module Plutonium
19
19
  form_errors_list: "mt-2 list-disc list-inside text-sm",
20
20
 
21
21
  # Label themes
22
- label: "mt-2 block mb-2 text-base font-semibold",
22
+ # The required marker is a `<abbr title="required">*</abbr>` which
23
+ # picks up the browser's default dotted underline — strip it and
24
+ # color the asterisk as a danger indicator instead.
25
+ label: "mt-2 block mb-2 text-base font-semibold [&_abbr]:no-underline [&_abbr]:border-0 [&_abbr]:cursor-default [&_abbr]:text-danger-500",
23
26
  invalid_label: "text-danger-700 dark:text-danger-400",
24
27
  valid_label: "text-success-700 dark:text-success-400",
25
28
  neutral_label: "text-[var(--pu-text)]",
@@ -46,8 +49,16 @@ module Plutonium
46
49
  valid_color: nil,
47
50
  neutral_color: nil,
48
51
 
49
- # File input
50
- file: "pu-input py-2 [&::file-selector-button]:mr-4 [&::file-selector-button]:px-4 [&::file-selector-button]:py-2 [&::file-selector-button]:bg-[var(--pu-surface-alt)] [&::file-selector-button]:border-0 [&::file-selector-button]:rounded-md [&::file-selector-button]:text-sm [&::file-selector-button]:font-semibold [&::file-selector-button]:text-[var(--pu-text-muted)] [&::file-selector-button]:hover:bg-[var(--pu-border)] [&::file-selector-button]:cursor-pointer [&::file-selector-button]:transition-colors",
52
+ # File input — keep pu-input's h-9 so the field matches the height
53
+ # of text inputs and slim-selects. The native file-selector-button
54
+ # fills the *full* height of the control and sits flush against the
55
+ # left edge (px-0 on the wrapper, no vertical margin), reading as a
56
+ # segmented button. A right border divides it from the filename text,
57
+ # and only the left corners are rounded so it nests inside pu-input.
58
+ # NOTE: pu-input ships its px-3/h-9 padding *unlayered*, so plain
59
+ # pl-0/py-0 utilities (in @layer utilities) lose to it — the !
60
+ # important variants are required to flatten the wrapper padding.
61
+ file: "pu-input pl-0! py-0! [&::file-selector-button]:h-full [&::file-selector-button]:align-middle [&::file-selector-button]:m-0 [&::file-selector-button]:mr-4 [&::file-selector-button]:px-4 [&::file-selector-button]:leading-[1.1] [&::file-selector-button]:bg-[var(--pu-surface-alt)] [&::file-selector-button]:border-0 [&::file-selector-button]:border-r [&::file-selector-button]:border-[var(--pu-input-border)] [&::file-selector-button]:rounded-none [&::file-selector-button]:rounded-l-md [&::file-selector-button]:text-sm [&::file-selector-button]:font-semibold [&::file-selector-button]:text-[var(--pu-text-muted)] [&::file-selector-button]:hover:bg-[var(--pu-border)] [&::file-selector-button]:cursor-pointer [&::file-selector-button]:transition-colors",
51
62
 
52
63
  # Hint themes
53
64
  hint: "pu-hint whitespace-pre-wrap",
@@ -25,11 +25,17 @@ module Plutonium
25
25
  # controller on the frame after showModal(). Mirrors the filter
26
26
  # slideover's pattern — see Centered for the same rationale.
27
27
  def base_dialog_classes
28
+ # The backdrop dim+blur is static (no [data-open] gating, no
29
+ # transition): a ::backdrop that fades its bg-color while carrying
30
+ # backdrop-filter re-rasterises the blur every frame and stutters
31
+ # the panel slide. Keeping it static lets only the panel animate
32
+ # (transform), composited smoothly. The backdrop snaps in at
33
+ # showModal() and is dropped when the dialog leaves the top layer
34
+ # on close(), so it still covers the panel's slide-out. Mirrors
35
+ # the .pu-dialog::backdrop rule in components.css.
28
36
  "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
29
37
  "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
30
- "backdrop:bg-transparent data-[open]:backdrop:bg-black/60 " \
31
- "data-[open]:backdrop:backdrop-blur-sm " \
32
- "backdrop:transition-[background-color] backdrop:duration-300 backdrop:ease-out " \
38
+ "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
33
39
  "rounded-none p-0 " \
34
40
  "open:flex flex-col " \
35
41
  "translate-x-full data-[open]:translate-x-0 " \
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.53.1"
2
+ VERSION = "0.55.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.53.1",
3
+ "version": "0.55.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",
@@ -513,12 +513,17 @@
513
513
  border-radius: var(--pu-radius-lg);
514
514
  }
515
515
 
516
+ /* Dim + frosted blur for the whole time the dialog is in the top layer.
517
+ Deliberately NOT transitioned and NOT gated on [data-open]: a ::backdrop
518
+ that repaints every frame (a bg-color fade) while carrying a
519
+ backdrop-filter forces the browser to re-rasterise the blur per frame,
520
+ which stutters the panel's open/close animation — and doubly so when a
521
+ nested confirm animates over a parent modal whose blur is still live.
522
+ Keeping it static lets the GPU rasterise the blur once; only the panel
523
+ animates (opacity/scale/translate, cheaply composited). The backdrop
524
+ snaps in at showModal() and is removed when the dialog leaves the top
525
+ layer on close(), so the dim still covers the panel's exit. */
516
526
  .pu-dialog::backdrop {
517
- background-color: transparent;
518
- transition: background-color 200ms ease-out;
519
- }
520
-
521
- .pu-dialog[data-open]::backdrop {
522
527
  background-color: rgb(0 0 0 / 0.6);
523
528
  backdrop-filter: blur(4px);
524
529
  }
@@ -649,6 +654,10 @@ body.pu-rail-pinned .icon-rail-pin-expand {
649
654
  box-shadow: var(--pu-shadow-lg);
650
655
  padding: 6px;
651
656
  animation: pu-rail-flyout-in 120ms ease-out;
657
+ /* Cap to the viewport so long menus scroll instead of overflowing. */
658
+ max-height: calc(100vh - 16px);
659
+ overflow-y: auto;
660
+ overscroll-behavior: contain;
652
661
  }
653
662
 
654
663
  @keyframes pu-rail-flyout-in {
@@ -679,3 +688,108 @@ body.pu-rail-pinned .icon-rail-pin-expand {
679
688
  background: var(--pu-surface-alt);
680
689
  color: var(--pu-text);
681
690
  }
691
+
692
+ /* ===================
693
+ FLASH — TOASTS & ALERTS
694
+
695
+ Self-contained component classes so flash colors render regardless of the
696
+ host app's Tailwind `content` scan. The partials build their class names
697
+ dynamically (bg-<color>-50, …), which Tailwind can only emit if it scans the
698
+ gem's views — it doesn't under v4's @import + @config source detection. By
699
+ baking the colors into Plutonium's own compiled stylesheet, every flash
700
+ variant works in any consuming app.
701
+ =================== */
702
+
703
+ /* --- Toast (fixed, dismissible banner) --- */
704
+ .pu-toast {
705
+ @apply fixed z-50 top-16 inset-x-0 mx-auto flex items-center w-full max-w-md p-4 rounded-lg shadow text-gray-500;
706
+ }
707
+ .dark .pu-toast {
708
+ @apply bg-gray-800 border border-gray-700 shadow-none;
709
+ }
710
+
711
+ .pu-toast-icon {
712
+ @apply inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg;
713
+ }
714
+
715
+ .pu-toast-message {
716
+ @apply ms-3 text-sm font-normal;
717
+ }
718
+
719
+ .pu-toast-close {
720
+ @apply ms-auto -mx-1.5 -my-1.5 rounded-lg focus:ring-2 p-1.5 inline-flex items-center justify-center h-8 w-8;
721
+ }
722
+ .dark .pu-toast-close {
723
+ @apply bg-gray-800 hover:bg-gray-700;
724
+ }
725
+
726
+ /* --- Alert (inline, flow banner) --- */
727
+ .pu-alert {
728
+ @apply flex items-center p-4 mb-4 rounded-lg;
729
+ }
730
+ .dark .pu-alert {
731
+ @apply bg-stone-300;
732
+ }
733
+
734
+ .pu-alert-message {
735
+ @apply ms-3 text-sm font-normal;
736
+ }
737
+
738
+ .pu-alert-close {
739
+ @apply ms-auto -mx-1.5 -my-1.5 rounded-lg focus:ring-2 p-1.5 inline-flex items-center justify-center h-8 w-8;
740
+ }
741
+ .dark .pu-alert-close {
742
+ @apply bg-stone-300 hover:bg-gray-700;
743
+ }
744
+
745
+ /* --- Variants: success / warning / danger / info ---
746
+ Each colors the container plus its icon and close button via descendants,
747
+ so the partial only sets one variant class on the wrapper. --- */
748
+
749
+ /* success */
750
+ .pu-toast-success { @apply bg-success-50; }
751
+ .dark .pu-toast-success { @apply text-success-400; }
752
+ .pu-toast-success .pu-toast-icon { @apply text-success-500 bg-success-100; }
753
+ .dark .pu-toast-success .pu-toast-icon { @apply bg-success-800 text-success-200; }
754
+ .pu-toast-success .pu-toast-close { @apply bg-success-50 text-success-400 hover:bg-success-200 focus:ring-success-400; }
755
+ .dark .pu-toast-success .pu-toast-close { @apply text-success-400; }
756
+ .pu-alert-success { @apply bg-success-50 text-success-800; }
757
+ .dark .pu-alert-success { @apply text-success-400; }
758
+ .pu-alert-success .pu-alert-close { @apply bg-success-50 text-success-500 hover:bg-success-200 focus:ring-success-400; }
759
+ .dark .pu-alert-success .pu-alert-close { @apply text-success-400; }
760
+
761
+ /* warning */
762
+ .pu-toast-warning { @apply bg-warning-50; }
763
+ .dark .pu-toast-warning { @apply text-warning-400; }
764
+ .pu-toast-warning .pu-toast-icon { @apply text-warning-500 bg-warning-100; }
765
+ .dark .pu-toast-warning .pu-toast-icon { @apply bg-warning-800 text-warning-200; }
766
+ .pu-toast-warning .pu-toast-close { @apply bg-warning-50 text-warning-400 hover:bg-warning-200 focus:ring-warning-400; }
767
+ .dark .pu-toast-warning .pu-toast-close { @apply text-warning-400; }
768
+ .pu-alert-warning { @apply bg-warning-50 text-warning-800; }
769
+ .dark .pu-alert-warning { @apply text-warning-400; }
770
+ .pu-alert-warning .pu-alert-close { @apply bg-warning-50 text-warning-500 hover:bg-warning-200 focus:ring-warning-400; }
771
+ .dark .pu-alert-warning .pu-alert-close { @apply text-warning-400; }
772
+
773
+ /* danger */
774
+ .pu-toast-danger { @apply bg-danger-50; }
775
+ .dark .pu-toast-danger { @apply text-danger-400; }
776
+ .pu-toast-danger .pu-toast-icon { @apply text-danger-500 bg-danger-100; }
777
+ .dark .pu-toast-danger .pu-toast-icon { @apply bg-danger-800 text-danger-200; }
778
+ .pu-toast-danger .pu-toast-close { @apply bg-danger-50 text-danger-400 hover:bg-danger-200 focus:ring-danger-400; }
779
+ .dark .pu-toast-danger .pu-toast-close { @apply text-danger-400; }
780
+ .pu-alert-danger { @apply bg-danger-50 text-danger-800; }
781
+ .dark .pu-alert-danger { @apply text-danger-400; }
782
+ .pu-alert-danger .pu-alert-close { @apply bg-danger-50 text-danger-500 hover:bg-danger-200 focus:ring-danger-400; }
783
+ .dark .pu-alert-danger .pu-alert-close { @apply text-danger-400; }
784
+
785
+ /* info (default / :notice) */
786
+ .pu-toast-info { @apply bg-info-50; }
787
+ .dark .pu-toast-info { @apply text-info-400; }
788
+ .pu-toast-info .pu-toast-icon { @apply text-info-500 bg-info-100; }
789
+ .dark .pu-toast-info .pu-toast-icon { @apply bg-info-800 text-info-200; }
790
+ .pu-toast-info .pu-toast-close { @apply bg-info-50 text-info-400 hover:bg-info-200 focus:ring-info-400; }
791
+ .dark .pu-toast-info .pu-toast-close { @apply text-info-400; }
792
+ .pu-alert-info { @apply bg-info-50 text-info-800; }
793
+ .dark .pu-alert-info { @apply text-info-400; }
794
+ .pu-alert-info .pu-alert-close { @apply bg-info-50 text-info-500 hover:bg-info-200 focus:ring-info-400; }
795
+ .dark .pu-alert-info .pu-alert-close { @apply text-info-400; }
@@ -1,14 +1,27 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="capture-url"
4
- // Sets the controller's own element's `value` to window.location.href
5
- // on connect capturing URL fragments (#tab-id) that the server never
6
- // sees over HTTP. Apply directly to any input/button whose value should
7
- // reflect the full client-side URL.
4
+ //
5
+ // The server never sees URL fragments (#tab-id), so a server-rendered
6
+ // `return_to` can't include the one the user is currently on. On connect we
7
+ // graft the live fragment onto this element's EXISTING value — we do not
8
+ // replace the value's path/query.
9
+ //
10
+ // Replacing the whole value (an earlier approach) broke modals: the element
11
+ // already holds the correct return target (e.g. the resource page), while the
12
+ // live browser URL may be the modal/action URL. Overwriting it sent a
13
+ // successful submit "back" to the bare action form — a blank page. Keeping the
14
+ // server value and only contributing the fragment is correct in every case.
8
15
  export default class extends Controller {
9
16
  connect() {
10
- if ("value" in this.element) {
11
- this.element.value = window.location.href
12
- }
17
+ if (!("value" in this.element)) return
18
+
19
+ const base = this.element.value
20
+ if (!base) return // no explicit return target; let the controller decide
21
+
22
+ const { hash } = window.location
23
+ if (!hash) return // no fragment to recover; keep the server value as-is
24
+
25
+ this.element.value = base.split("#")[0] + hash
13
26
  }
14
27
  }
@@ -58,11 +58,20 @@ export default class extends Controller {
58
58
  }
59
59
  }
60
60
 
61
- async discard() {
61
+ discard() {
62
62
  this.forceClose = true;
63
- await this.#closeConfirm();
64
- // Hand off to remote-modal so the parent modal animates out
65
- // instead of snapping shut.
63
+ // Snap the confirm shut with no exit animation, then hand straight
64
+ // off to remote-modal so the parent modal animates out as a single,
65
+ // smooth motion.
66
+ //
67
+ // Animating the confirm out *first* (the old behaviour) stuttered:
68
+ // its fade played on top of the parent modal's still-live backdrop
69
+ // `backdrop-filter: blur()`, forcing the compositor to re-rasterise
70
+ // the blurred viewport every frame — and its display:none reflow
71
+ // landed partway through the modal's own close transition. We're
72
+ // tearing the whole modal down anyway, so the confirm doesn't need
73
+ // its own choreography.
74
+ this.#snapConfirmClosed();
66
75
  this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
67
76
  }
68
77
 
@@ -122,6 +131,10 @@ export default class extends Controller {
122
131
  }
123
132
 
124
133
  #onCancel(event) {
134
+ // `cancel` bubbles: a descendant's cancel (e.g. an <input type="file">
135
+ // whose picker was dismissed) reaches this listener. Only the dialog's
136
+ // own cancel (Escape) — target === the dialog — should prompt.
137
+ if (event.target !== this.dialog) return;
125
138
  if (this.forceClose || this.submitting) return;
126
139
  if (!this.#isDirty()) return;
127
140
  event.preventDefault();
@@ -153,6 +166,17 @@ export default class extends Controller {
153
166
  }
154
167
  }
155
168
 
169
+ // Close the confirm immediately, skipping its exit transition. Used by
170
+ // discard(), where the parent modal is about to animate away and a
171
+ // separate confirm fade would only stutter against the modal's live
172
+ // backdrop blur.
173
+ #snapConfirmClosed() {
174
+ if (!this.hasConfirmDialogTarget) return;
175
+ const d = this.confirmDialogTarget;
176
+ d.removeAttribute("data-open");
177
+ if (d.open) d.close();
178
+ }
179
+
156
180
  async #closeConfirm() {
157
181
  if (!this.hasConfirmDialogTarget) return;
158
182
  const d = this.confirmDialogTarget;
@@ -115,13 +115,16 @@ export default class extends Controller {
115
115
  panel.style.left = `${triggerRect.right + 4}px`
116
116
  panel.style.top = `${triggerRect.top}px`
117
117
 
118
- // Shift up if the panel would overflow the viewport bottom.
118
+ // Shift up if the panel would overflow the viewport bottom, but never
119
+ // past the top edge — the inner panel scrolls (max-height) when the
120
+ // menu is taller than the viewport.
119
121
  requestAnimationFrame(() => {
120
122
  const panelRect = panel.getBoundingClientRect()
121
123
  const viewportH = window.innerHeight
122
124
  if (panelRect.bottom > viewportH - 8) {
123
125
  const overflow = panelRect.bottom - (viewportH - 8)
124
- panel.style.top = `${parseFloat(panel.style.top) - overflow}px`
126
+ const top = Math.max(8, parseFloat(panel.style.top) - overflow)
127
+ panel.style.top = `${top}px`
125
128
  }
126
129
  })
127
130
  }
@@ -1,6 +1,7 @@
1
1
  // Import controllers here
2
2
  import ResourceHeaderController from "./resource_header_controller.js"
3
3
  import NestedResourceFormFieldsController from "./nested_resource_form_fields_controller.js"
4
+ import StructuredInputRowController from "./structured_input_row_controller.js"
4
5
  import FormController from "./form_controller.js"
5
6
  import ResourceDropDownController from "./resource_drop_down_controller.js"
6
7
  import ResourceCollapseController from "./resource_collapse_controller.js"
@@ -40,6 +41,7 @@ export default function (application) {
40
41
  application.register("sidebar", SidebarController)
41
42
  application.register("resource-header", ResourceHeaderController)
42
43
  application.register("nested-resource-form-fields", NestedResourceFormFieldsController)
44
+ application.register("structured-input-row", StructuredInputRowController)
43
45
  application.register("form", FormController)
44
46
  application.register("resource-drop-down", ResourceDropDownController)
45
47
  application.register("resource-collapse", ResourceCollapseController)
@@ -47,6 +47,11 @@ export default class extends Controller {
47
47
  }
48
48
 
49
49
  #onCancel(event) {
50
+ // `cancel` bubbles, so a descendant firing it — most notably an
51
+ // <input type="file"> whose picker was dismissed — reaches this
52
+ // listener. That is not a request to close the modal; only the
53
+ // dialog's own cancel (Escape) targets the dialog element itself.
54
+ if (event.target !== this.element) return;
50
55
  // Another listener (typically dirty-form-guard) already handled
51
56
  // this — don't double-process.
52
57
  if (event.defaultPrevented) return;
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="structured-input-row"
4
+ //
5
+ // Soft-removes a classless structured-input row by DISABLING its fields — a
6
+ // disabled <fieldset> is omitted from form submission, so the server simply
7
+ // receives the payload without that row and rebuilds the JSON column from what
8
+ // it gets (no _destroy marker needed). The row stays in the DOM, collapsed to a
9
+ // "Removed — Restore" bar, so it can be restored (re-enabled) before saving.
10
+ export default class extends Controller {
11
+ static targets = ["content", "removed"]
12
+
13
+ remove(e) {
14
+ e.preventDefault()
15
+ this.contentTarget.disabled = true
16
+ this.contentTarget.hidden = true
17
+ this.removedTarget.hidden = false
18
+ }
19
+
20
+ restore(e) {
21
+ e.preventDefault()
22
+ this.contentTarget.disabled = false
23
+ this.contentTarget.hidden = false
24
+ this.removedTarget.hidden = true
25
+ }
26
+ }