plutonium 0.53.1 → 0.54.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.
@@ -3,25 +3,16 @@
3
3
  <%
4
4
  next unless msg.present?
5
5
 
6
- color = case type.to_sym
7
- when :success
8
- # bg-green-50 text-green-800 text-green-500 text-green-400 ring-green-400
9
- "green"
10
- when :warning
11
- # bg-yellow-50 text-yellow-800 text-yellow-500 text-yellow-400 ring-yellow-400
12
- "yellow"
13
- when :alert, :danger, :error
14
- # bg-red-50 text-red-800 text-red-500 text-red-400 ring-red-400
15
- "red"
16
- else
17
- # bg-blue-50 text-blue-800 text-blue-500 text-blue-400 ring-blue-400
18
- "blue"
19
- end
6
+ variant = {
7
+ success: "success",
8
+ warning: "warning",
9
+ alert: "danger", danger: "danger", error: "danger"
10
+ }.fetch(type.to_sym, "info")
20
11
  %>
21
12
 
22
13
  <div data-controller="resource-dismiss"
23
14
  data-resource-dismiss-after-value="6000"
24
- class="flex items-center p-4 mb-4 text-<%= color %>-800 rounded-lg bg-<%= color %>-50 dark:bg-stone-300 dark:text-<%= color %>-400"
15
+ class="pu-alert pu-alert-<%= variant %>"
25
16
  role="alert">
26
17
  <% case type.to_sym %>
27
18
  <% when :success %>
@@ -41,9 +32,9 @@
41
32
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.147 15.085a7.159 7.159 0 0 1-6.189 3.307A6.713 6.713 0 0 1 3.1 15.444c-2.679-4.513.287-8.737.888-9.548A4.373 4.373 0 0 0 5 1.608c1.287.953 6.445 3.218 5.537 10.5 1.5-1.122 2.706-3.01 2.853-6.14 1.433 1.049 3.993 5.395 1.757 9.117Z"/>
42
33
  </svg>
43
34
  <% end %>
44
- <div class="ms-3 text-sm font-normal"><%= msg %></div>
35
+ <div class="pu-alert-message"><%= msg %></div>
45
36
  <button type="button"
46
- class="ms-auto -mx-1.5 -my-1.5 bg-<%= color %>-50 text-<%= color %>-500 rounded-lg focus:ring-2 focus:ring-<%= color %>-400 p-1.5 hover:bg-<%= color %>-200 inline-flex items-center justify-center h-8 w-8 dark:bg-stone-300 dark:text-<%= color %>-400 dark:hover:bg-gray-700"
37
+ class="pu-alert-close"
47
38
  data-action="click->resource-dismiss#dismiss"
48
39
  aria-label="Close">
49
40
  <span class="sr-only">Close</span>
@@ -2,28 +2,19 @@
2
2
  <%
3
3
  next unless msg.present?
4
4
 
5
- color = case type.to_sym
6
- when :success
7
- # text-green-500 bg-green-100 bg-green-800 text-green-200 bg-green-50 text-green-400 ring-green-400
8
- "green"
9
- when :warning
10
- # text-yellow-500 bg-yellow-100 bg-yellow-800 text-yellow-200 bg-yellow-50 text-yellow-400 ring-yellow-400
11
- "yellow"
12
- when :alert, :danger, :error
13
- # text-red-500 bg-red-100 bg-red-800 text-red-200 bg-red-50 text-red-400 ring-red-400
14
- "red"
15
- else
16
- # text-blue-500 bg-blue-100 bg-blue-800 text-blue-200 bg-blue-50 text-blue-400 ring-blue-400
17
- "blue"
18
- end
5
+ variant = {
6
+ success: "success",
7
+ warning: "warning",
8
+ alert: "danger", danger: "danger", error: "danger"
9
+ }.fetch(type.to_sym, "info")
19
10
  %>
20
11
 
21
12
  <div data-controller="resource-dismiss"
22
13
  data-resource-dismiss-after-value="6000"
23
- class="fixed z-50 top-16 inset-x-0 mx-auto flex items-center w-full max-w-md p-4 text-gray-500 bg-<%= color %>-50 rounded-lg shadow dark:text-<%= color %>-400 dark:bg-gray-800 dark:border dark:border-gray-700 dark:shadow-none"
14
+ class="pu-toast pu-toast-<%= variant %>"
24
15
  role="alert">
25
16
 
26
- <div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-<%= color %>-500 bg-<%= color %>-100 rounded-lg dark:bg-<%= color %>-800 dark:text-<%= color %>-200">
17
+ <div class="pu-toast-icon">
27
18
  <% case type.to_sym %>
28
19
  <% when :success %>
29
20
  <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
@@ -43,9 +34,9 @@
43
34
  </svg>
44
35
  <% end %>
45
36
  </div>
46
- <div class="ms-3 text-sm font-normal"><%= msg %></div>
37
+ <div class="pu-toast-message"><%= msg %></div>
47
38
  <button type="button"
48
- class="ms-auto -mx-1.5 -my-1.5 bg-<%= color %>-50 text-<%= color %>-400 rounded-lg focus:ring-2 focus:ring-<%= color %>-400 p-1.5 hover:bg-<%= color %>-200 inline-flex items-center justify-center h-8 w-8 dark:text-<%= color %>-400 dark:bg-gray-800 dark:hover:bg-gray-700"
39
+ class="pu-toast-close"
49
40
  data-action="click->resource-dismiss#dismiss"
50
41
  aria-label="Close">
51
42
  <span class="sr-only">Close</span>
@@ -0,0 +1,178 @@
1
+ # Interaction Repeater Inputs
2
+
3
+ **Date:** 2026-06-01
4
+ **Status:** Design — pending review
5
+
6
+ ## Problem
7
+
8
+ Interactions (`Plutonium::Resource::Interaction`) collect scalar inputs via
9
+ `attribute`, but have no first-class way to collect a **repeating group of
10
+ fields** — e.g. a variable-length list of `{label, phone_number}` contacts —
11
+ as structured input the interaction can validate and use in `execute`.
12
+
13
+ The repeater UX already exists for resource forms (the
14
+ `nested-resource-form-fields` Stimulus controller: add/remove rows, `<template>`
15
+ cloning). But it is **model-backed**: the renderer
16
+ (`Plutonium::UI::Form::Concerns::RendersNestedResourceFields` →
17
+ `NestedFieldContext`) sources its row metadata (`:class`, `:macro`, `:limit`,
18
+ multiplicity) from `resource_class.all_nested_attributes_options`, which only
19
+ reflects ActiveRecord associations on the acted-on resource.
20
+
21
+ For an interaction collecting a classless list, there is no association and no
22
+ class. The current behaviour (characterized in
23
+ `test/plutonium/ui/form/interaction_nested_input_test.rb`):
24
+
25
+ - `nested_input` is registered on interactions and param coercion works, **but**
26
+ - `nested_attribute_options` resolves to `{}`, so `blank_object` is `nil` →
27
+ `nest_one`/`nest_many` have no template object → the nested UI renders nothing.
28
+ - The one case that *does* work is when the interaction's nested attribute
29
+ happens to mirror a real association on the acted-on resource (the README's
30
+ `CreateUserInteraction` building a `User` with `has_many :contacts`).
31
+
32
+ So interactions have the *param* half of nested inputs but no working *rendering*
33
+ half for the classless case, and the failure is silent.
34
+
35
+ ## Goal
36
+
37
+ Let an interaction declare a **classless repeating field group** that:
38
+
39
+ - renders with the existing repeater UX (add/remove rows, template cloning,
40
+ delete checkbox),
41
+ - collects into the interaction as an **array of plain hashes**
42
+ (`contacts => [{label:, phone_number:}, …]`),
43
+ - needs **no backing class** — just a fields definition.
44
+
45
+ Non-goals (explicitly out of scope):
46
+
47
+ - A single (non-repeater) variant. Repeater only; value is always an array.
48
+ - Type coercion of row values. Rows are plain hashes; the interaction validates
49
+ them itself.
50
+ - Reusing/extending model-backed `nested_input` semantics for the classless case.
51
+
52
+ ## Feasibility anchor
53
+
54
+ Phlexi already supports hash-backed rendering. `Phlexi::Field::Support::Value.from`:
55
+
56
+ ```ruby
57
+ return object[key] if object.is_a?(Hash)
58
+ object.public_send(key) if object.respond_to?(key)
59
+ ```
60
+
61
+ So a row can be a plain `Hash`, and the blank/template row can be `{}` (every
62
+ field reads `nil` → empty). No synthesized classes are required.
63
+
64
+ ## Design
65
+
66
+ ### 1. DSL — `repeater`
67
+
68
+ A new, self-contained DSL on the interaction base. One call declares everything:
69
+
70
+ ```ruby
71
+ class CreateUserInteraction < Plutonium::Resource::Interaction
72
+ attribute :first_name, :string
73
+
74
+ repeater :contacts do |f|
75
+ f.input :label
76
+ f.input :phone_number
77
+ end
78
+
79
+ # or, reusing an existing fields definition:
80
+ # repeater :addresses, using: AddressFields, fields: %i[label map_url]
81
+
82
+ # options: limit: (default 10)
83
+ end
84
+ ```
85
+
86
+ `repeater :name` will:
87
+
88
+ 1. declare `attribute :name` defaulting to `[]`,
89
+ 2. register a **classless** `name_attributes=` collector (see §2),
90
+ 3. register render config (the fields definition, `multiple: true`, `limit`).
91
+
92
+ A distinct name (not `nested_input`) is intentional: it signals different
93
+ semantics (classless, collects hashes) and keeps a clean, conditional-free
94
+ implementation path separate from model-backed `nested_input`.
95
+
96
+ ### 2. Param handling
97
+
98
+ `name_attributes=` accepts what the form submits — either an `Array` of row
99
+ hashes or a `Hash` keyed by index (`{"0" => {...}, "1" => {...}}`, Rails' nested
100
+ form shape). For each row:
101
+
102
+ - skip if `_destroy` is truthy (`1`/`"1"`/`true`/`"true"`),
103
+ - skip if every value is blank (the empty trailing/blank rows),
104
+ - otherwise keep the row as a symbolized hash with `_destroy` removed.
105
+
106
+ Store the result as `name` = `Array<Hash>`. The `name` reader returns that array
107
+ (used both by `execute` and by the renderer to repopulate on re-render).
108
+
109
+ ### 3. Rendering
110
+
111
+ Reuse the existing repeater chrome. Add a **classless render path** that does not
112
+ touch `all_nested_attributes_options`:
113
+
114
+ - config (fields, `multiple: true`, `limit`) comes from the `repeater`
115
+ declaration,
116
+ - the template/blank row object is `{}`,
117
+ - existing rows come from the array of hashes already on the attribute (so a
118
+ validation-failed re-render repopulates),
119
+ - field naming via `nest_many(:contacts, as: :contacts_attributes, …)` →
120
+ `interaction[contacts_attributes][N][label]`.
121
+
122
+ The resource (model-backed) render path is left untouched and stays covered by
123
+ the existing characterization tests
124
+ (`test/integration/admin_portal/nested_form_rendering_test.rb`).
125
+
126
+ > Implementation detail deferred to the plan: whether the classless path is a
127
+ > sibling context to `NestedFieldContext` or a generalization of it, and how the
128
+ > `repeater` fields block maps onto the existing `NestedInputsDefinition`. The
129
+ > design constraint is only that the resource path does not change behaviour.
130
+
131
+ ### 4. `nested_input` raises when no class is resolvable
132
+
133
+ Convert the silent classless failure into a guiding error. When the nested-field
134
+ renderer cannot resolve a class to build rows (no `object_class`, and no `:class`
135
+ from association metadata), raise:
136
+
137
+ ```
138
+ `nested_input :contacts` could not resolve a class to build its rows.
139
+ If this is an interaction collecting plain inputs, use `repeater :contacts` instead.
140
+ ```
141
+
142
+ This keeps the legitimate model-backed `nested_input` working (class present →
143
+ renders) and fails loudly only where it is actually broken. It also guards
144
+ genuinely misconfigured resource nested inputs.
145
+
146
+ ### 5. `accepts_nested_attributes_for` is unchanged
147
+
148
+ It stays available on interactions for the deliberate case of building real
149
+ model instances in `execute`. `repeater` does not use or replace it.
150
+
151
+ ### 6. Documentation
152
+
153
+ Update the interaction README: document `repeater` for classless repeating
154
+ input; note that `nested_input` is model-backed and now errors without a
155
+ resolvable class.
156
+
157
+ ## Testing
158
+
159
+ - **Param round-trip** (unit): `contacts_attributes=` with an `Array` and with a
160
+ Rails index-keyed `Hash` → `contacts` is an array of symbolized hashes;
161
+ `_destroy` and all-blank rows dropped.
162
+ - **Render** (integration): a dummy interaction with a `repeater`, wired to an
163
+ interactive action, GET its form → assert the repeater HTML — controller
164
+ container + limit, `<template>`, fieldset, `interaction[contacts_attributes]
165
+ [NEW_RECORD][label]` naming, add button, delete checkbox. Fixture built via
166
+ the `pu:*` generators per project convention.
167
+ - **Guard** (unit/integration): a classless `nested_input` on an interaction
168
+ raises the guiding error. Update the existing characterization test
169
+ (`interaction_nested_input_test.rb`) from "blank_object is nil" to "raises".
170
+ - **Regression**: resource nested fields unchanged — existing characterization
171
+ tests stay green.
172
+
173
+ ## Risk / breaking change
174
+
175
+ Making `nested_input` raise without a resolvable class is a (small) breaking
176
+ change for any interaction relying on the silently-broken classless path — but
177
+ that path renders nothing today, so there is no working behaviour to break. The
178
+ model-backed path is preserved.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.52.0)
4
+ plutonium (0.53.1)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -13,16 +13,23 @@ module Plutonium
13
13
  def validate_engine!(engine)
14
14
  return if supported_engine?(engine)
15
15
 
16
- # TODO: make the error link to documentation on how to ensure that your engine is supported
17
- raise ArgumentError, "#{engine} must include Plutonium::Engine to call register resources"
16
+ raise ArgumentError,
17
+ "#{engine} must include Plutonium::Engine to register resources. " \
18
+ "See https://radioactive-labs.github.io/plutonium-core/reference/app/packages " \
19
+ "for how to make an engine Plutonium-aware."
18
20
  end
19
21
 
20
22
  # Checks if the current engine supports Plutonium features.
21
23
  #
22
24
  # @return [Boolean] True if the engine includes Plutonium::Engine, false otherwise.
23
25
  def supported_engine?(engine)
24
- # TODO: fix constant being out of sync after reload during development
25
- Plutonium.configuration.development? ? engine.respond_to?(:dom_id) : engine.include?(Plutonium::Engine)
26
+ # Match by module name rather than object identity. In development the
27
+ # framework is reloaded, so `Plutonium::Engine` is reassigned to a
28
+ # fresh module object while already-loaded engines still include the
29
+ # previous one — making an `include?(Plutonium::Engine)` identity check
30
+ # spuriously false. A module's name survives the reassignment, so this
31
+ # stays correct in both development and production without a branch.
32
+ engine.ancestors.any? { |ancestor| ancestor.name == "Plutonium::Engine" }
26
33
  end
27
34
  end
28
35
  end
@@ -104,11 +104,18 @@ module Plutonium
104
104
  end
105
105
 
106
106
  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).
107
+ # Only carry an *explicit* return_to. We deliberately do NOT fall
108
+ # back to request.original_url: for interactive-action forms that URL
109
+ # is the action's own (modal-only) path, and submitting it back would
110
+ # "return" to a bare standalone form — a blank page. When absent, the
111
+ # controller computes the right destination (redirect_url_after_submit
112
+ # / redirect_url_after_action_on, both → resource_url_for).
113
+ #
114
+ # capture-url grafts the live URL fragment (#tab-id) onto this value
115
+ # on connect (the server never sees fragments), but only when a base
116
+ # value is present.
110
117
  input name: "return_to",
111
- value: request.params[:return_to] || request.original_url,
118
+ value: request.params[:return_to],
112
119
  type: :hidden,
113
120
  hidden: true,
114
121
  data: {controller: "capture-url"}
@@ -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",
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.53.1"
2
+ VERSION = "0.54.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.54.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",
@@ -649,6 +649,10 @@ body.pu-rail-pinned .icon-rail-pin-expand {
649
649
  box-shadow: var(--pu-shadow-lg);
650
650
  padding: 6px;
651
651
  animation: pu-rail-flyout-in 120ms ease-out;
652
+ /* Cap to the viewport so long menus scroll instead of overflowing. */
653
+ max-height: calc(100vh - 16px);
654
+ overflow-y: auto;
655
+ overscroll-behavior: contain;
652
656
  }
653
657
 
654
658
  @keyframes pu-rail-flyout-in {
@@ -679,3 +683,108 @@ body.pu-rail-pinned .icon-rail-pin-expand {
679
683
  background: var(--pu-surface-alt);
680
684
  color: var(--pu-text);
681
685
  }
686
+
687
+ /* ===================
688
+ FLASH — TOASTS & ALERTS
689
+
690
+ Self-contained component classes so flash colors render regardless of the
691
+ host app's Tailwind `content` scan. The partials build their class names
692
+ dynamically (bg-<color>-50, …), which Tailwind can only emit if it scans the
693
+ gem's views — it doesn't under v4's @import + @config source detection. By
694
+ baking the colors into Plutonium's own compiled stylesheet, every flash
695
+ variant works in any consuming app.
696
+ =================== */
697
+
698
+ /* --- Toast (fixed, dismissible banner) --- */
699
+ .pu-toast {
700
+ @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;
701
+ }
702
+ .dark .pu-toast {
703
+ @apply bg-gray-800 border border-gray-700 shadow-none;
704
+ }
705
+
706
+ .pu-toast-icon {
707
+ @apply inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg;
708
+ }
709
+
710
+ .pu-toast-message {
711
+ @apply ms-3 text-sm font-normal;
712
+ }
713
+
714
+ .pu-toast-close {
715
+ @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;
716
+ }
717
+ .dark .pu-toast-close {
718
+ @apply bg-gray-800 hover:bg-gray-700;
719
+ }
720
+
721
+ /* --- Alert (inline, flow banner) --- */
722
+ .pu-alert {
723
+ @apply flex items-center p-4 mb-4 rounded-lg;
724
+ }
725
+ .dark .pu-alert {
726
+ @apply bg-stone-300;
727
+ }
728
+
729
+ .pu-alert-message {
730
+ @apply ms-3 text-sm font-normal;
731
+ }
732
+
733
+ .pu-alert-close {
734
+ @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;
735
+ }
736
+ .dark .pu-alert-close {
737
+ @apply bg-stone-300 hover:bg-gray-700;
738
+ }
739
+
740
+ /* --- Variants: success / warning / danger / info ---
741
+ Each colors the container plus its icon and close button via descendants,
742
+ so the partial only sets one variant class on the wrapper. --- */
743
+
744
+ /* success */
745
+ .pu-toast-success { @apply bg-success-50; }
746
+ .dark .pu-toast-success { @apply text-success-400; }
747
+ .pu-toast-success .pu-toast-icon { @apply text-success-500 bg-success-100; }
748
+ .dark .pu-toast-success .pu-toast-icon { @apply bg-success-800 text-success-200; }
749
+ .pu-toast-success .pu-toast-close { @apply bg-success-50 text-success-400 hover:bg-success-200 focus:ring-success-400; }
750
+ .dark .pu-toast-success .pu-toast-close { @apply text-success-400; }
751
+ .pu-alert-success { @apply bg-success-50 text-success-800; }
752
+ .dark .pu-alert-success { @apply text-success-400; }
753
+ .pu-alert-success .pu-alert-close { @apply bg-success-50 text-success-500 hover:bg-success-200 focus:ring-success-400; }
754
+ .dark .pu-alert-success .pu-alert-close { @apply text-success-400; }
755
+
756
+ /* warning */
757
+ .pu-toast-warning { @apply bg-warning-50; }
758
+ .dark .pu-toast-warning { @apply text-warning-400; }
759
+ .pu-toast-warning .pu-toast-icon { @apply text-warning-500 bg-warning-100; }
760
+ .dark .pu-toast-warning .pu-toast-icon { @apply bg-warning-800 text-warning-200; }
761
+ .pu-toast-warning .pu-toast-close { @apply bg-warning-50 text-warning-400 hover:bg-warning-200 focus:ring-warning-400; }
762
+ .dark .pu-toast-warning .pu-toast-close { @apply text-warning-400; }
763
+ .pu-alert-warning { @apply bg-warning-50 text-warning-800; }
764
+ .dark .pu-alert-warning { @apply text-warning-400; }
765
+ .pu-alert-warning .pu-alert-close { @apply bg-warning-50 text-warning-500 hover:bg-warning-200 focus:ring-warning-400; }
766
+ .dark .pu-alert-warning .pu-alert-close { @apply text-warning-400; }
767
+
768
+ /* danger */
769
+ .pu-toast-danger { @apply bg-danger-50; }
770
+ .dark .pu-toast-danger { @apply text-danger-400; }
771
+ .pu-toast-danger .pu-toast-icon { @apply text-danger-500 bg-danger-100; }
772
+ .dark .pu-toast-danger .pu-toast-icon { @apply bg-danger-800 text-danger-200; }
773
+ .pu-toast-danger .pu-toast-close { @apply bg-danger-50 text-danger-400 hover:bg-danger-200 focus:ring-danger-400; }
774
+ .dark .pu-toast-danger .pu-toast-close { @apply text-danger-400; }
775
+ .pu-alert-danger { @apply bg-danger-50 text-danger-800; }
776
+ .dark .pu-alert-danger { @apply text-danger-400; }
777
+ .pu-alert-danger .pu-alert-close { @apply bg-danger-50 text-danger-500 hover:bg-danger-200 focus:ring-danger-400; }
778
+ .dark .pu-alert-danger .pu-alert-close { @apply text-danger-400; }
779
+
780
+ /* info (default / :notice) */
781
+ .pu-toast-info { @apply bg-info-50; }
782
+ .dark .pu-toast-info { @apply text-info-400; }
783
+ .pu-toast-info .pu-toast-icon { @apply text-info-500 bg-info-100; }
784
+ .dark .pu-toast-info .pu-toast-icon { @apply bg-info-800 text-info-200; }
785
+ .pu-toast-info .pu-toast-close { @apply bg-info-50 text-info-400 hover:bg-info-200 focus:ring-info-400; }
786
+ .dark .pu-toast-info .pu-toast-close { @apply text-info-400; }
787
+ .pu-alert-info { @apply bg-info-50 text-info-800; }
788
+ .dark .pu-alert-info { @apply text-info-400; }
789
+ .pu-alert-info .pu-alert-close { @apply bg-info-50 text-info-500 hover:bg-info-200 focus:ring-info-400; }
790
+ .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
  }
@@ -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;