plutonium 0.51.0 → 0.53.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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-app/SKILL.md +2 -0
- data/.claude/skills/plutonium-auth/SKILL.md +6 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
- data/.claude/skills/plutonium-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +31 -7
- data/.claude/skills/plutonium-testing/SKILL.md +3 -1
- data/.claude/skills/plutonium-ui/SKILL.md +32 -8
- data/CHANGELOG.md +33 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +258 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +2 -2
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -50
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/guides/authentication.md +11 -6
- data/docs/guides/authorization.md +3 -3
- data/docs/guides/creating-packages.md +8 -11
- data/docs/guides/custom-actions.md +8 -2
- data/docs/guides/customizing-ui.md +259 -0
- data/docs/guides/index.md +49 -32
- data/docs/guides/multi-tenancy.md +14 -6
- data/docs/guides/nested-resources.md +69 -0
- data/docs/guides/search-filtering.md +6 -0
- data/docs/guides/testing.md +5 -1
- data/docs/guides/theming.md +14 -1
- data/docs/guides/user-invites.md +10 -4
- data/docs/guides/user-profile.md +8 -0
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- data/docs/reference/app/generators.md +4 -4
- data/docs/reference/auth/accounts.md +7 -8
- data/docs/reference/auth/index.md +1 -1
- data/docs/reference/behavior/policies.md +2 -2
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/index.md +67 -55
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +5 -4
- data/docs/reference/tenancy/entity-scoping.md +14 -8
- data/docs/reference/tenancy/index.md +1 -1
- data/docs/reference/tenancy/invites.md +12 -5
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/reference/ui/tables.md +8 -4
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
- data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/invites/install_generator.rb +44 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +17 -2
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/component/methods.rb +1 -0
- data/lib/plutonium/ui/form/base.rb +32 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +14 -8
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +8 -4
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +7 -4
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +7 -4
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +4 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- data/yarn.lock +108 -1
- metadata +52 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
|
@@ -75,20 +75,21 @@ module Plutonium
|
|
|
75
75
|
|
|
76
76
|
def render_image_slot(size:)
|
|
77
77
|
value = field_value(slots[:image])
|
|
78
|
-
return unless value
|
|
79
|
-
src = image_src_for(value)
|
|
80
|
-
return unless src
|
|
81
78
|
|
|
82
79
|
if size == :cover
|
|
80
|
+
# Cover is a full-width banner, not an avatar: only render when an
|
|
81
|
+
# actual image resolves (no deterministic fallback).
|
|
82
|
+
src = Plutonium::UI::Avatar.resolve_image_src(value, helpers)
|
|
83
|
+
return unless src
|
|
84
|
+
|
|
83
85
|
div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
84
86
|
img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
|
|
85
87
|
end
|
|
86
88
|
else
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
89
|
+
# Small avatar slot: render the resolved image, or Avatar's generic
|
|
90
|
+
# icon fallback. No subject is passed, so image-less cards fall back
|
|
91
|
+
# to the local icon rather than a per-card request to Navii.
|
|
92
|
+
Avatar(src: value, size: :lg, alt: header_text.to_s)
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -163,7 +164,7 @@ module Plutonium
|
|
|
163
164
|
url = route_options_to_url(show.route_options, record)
|
|
164
165
|
a(
|
|
165
166
|
href: url,
|
|
166
|
-
data: {row_click_target: "show", turbo_frame: show.turbo_frame},
|
|
167
|
+
data: {row_click_target: "show", turbo_frame: show.turbo_frame(resource_definition)},
|
|
167
168
|
class: "sr-only",
|
|
168
169
|
tabindex: "-1",
|
|
169
170
|
"aria-label": "Open #{header_text}"
|
|
@@ -192,23 +193,6 @@ module Plutonium
|
|
|
192
193
|
record.public_send(name)
|
|
193
194
|
end
|
|
194
195
|
|
|
195
|
-
# Resolves a slot value to an image URL. Supports:
|
|
196
|
-
# - ActiveStorage attachments (`record.avatar` -> Attached::One/Many)
|
|
197
|
-
# - Shrine uploaders (`record.avatar` -> UploadedFile, responds to :url)
|
|
198
|
-
# - Plain URL strings ("https://..." or "/uploads/...")
|
|
199
|
-
def image_src_for(value)
|
|
200
|
-
return nil if value.nil?
|
|
201
|
-
if value.respond_to?(:attached?)
|
|
202
|
-
value.attached? ? helpers.url_for(value) : nil
|
|
203
|
-
elsif value.respond_to?(:url)
|
|
204
|
-
value.url
|
|
205
|
-
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
206
|
-
value
|
|
207
|
-
end
|
|
208
|
-
rescue ArgumentError, URI::InvalidURIError
|
|
209
|
-
nil
|
|
210
|
-
end
|
|
211
|
-
|
|
212
196
|
def row_actions
|
|
213
197
|
@row_actions ||= resource_definition.defined_actions.values.select { |a|
|
|
214
198
|
a.collection_record_action? && a.permitted_by?(record_policy)
|
|
@@ -9,9 +9,26 @@ module Plutonium
|
|
|
9
9
|
slot :close
|
|
10
10
|
slot :footer
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Sizes that all modal subclasses must implement entries for in
|
|
13
|
+
# their SIZE_CLASSES table. `:auto` is content-driven (`w-fit`
|
|
14
|
+
# with a viewport cap and a sensible floor) and is the only way
|
|
15
|
+
# to avoid clipping forms whose natural width exceeds the
|
|
16
|
+
# default. Sizes intentionally mirror Tailwind's max-w-* scale
|
|
17
|
+
# so a definition that says `size: :xl` reads predictably.
|
|
18
|
+
VALID_SIZES = [:sm, :md, :lg, :xl, :auto, :full].freeze
|
|
19
|
+
|
|
20
|
+
# Resolves the concrete modal class for a definition's `modal_mode`
|
|
21
|
+
# symbol. Unknown / `false` modes fall back to `Slideover` so call
|
|
22
|
+
# sites can stay branchless.
|
|
23
|
+
def self.class_for_mode(mode)
|
|
24
|
+
(mode == :centered) ? Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(title: nil, description: nil, size: :md)
|
|
13
28
|
@title = title
|
|
14
29
|
@description = description
|
|
30
|
+
@size = size
|
|
31
|
+
validate_size!
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
def view_template(&block)
|
|
@@ -54,9 +71,27 @@ module Plutonium
|
|
|
54
71
|
end
|
|
55
72
|
|
|
56
73
|
def dialog_classes
|
|
74
|
+
"#{base_dialog_classes} #{size_classes}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Positioning, backdrop, transitions — everything that does
|
|
78
|
+
# not vary with `size`. Width/height tokens live in
|
|
79
|
+
# `size_classes` so size keys can fully replace them
|
|
80
|
+
# (notably `:auto`, which needs `w-fit` instead of `w-full`).
|
|
81
|
+
def base_dialog_classes
|
|
57
82
|
raise NotImplementedError
|
|
58
83
|
end
|
|
59
84
|
|
|
85
|
+
def size_classes
|
|
86
|
+
self.class::SIZE_CLASSES.fetch(@size)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_size!
|
|
90
|
+
return if VALID_SIZES.include?(@size)
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"modal size must be one of #{VALID_SIZES.inspect}, got #{@size.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
60
95
|
def inner_classes
|
|
61
96
|
"flex flex-col h-full max-h-[inherit] min-h-0"
|
|
62
97
|
end
|
|
@@ -4,16 +4,34 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Modal
|
|
6
6
|
class Centered < Plutonium::UI::Modal::Base
|
|
7
|
+
# Width tokens for each VALID_SIZES key. `:md` reproduces the
|
|
8
|
+
# historical default (`w-full max-w-xl`); `:auto` drops `w-full`
|
|
9
|
+
# so the dialog hugs its content, with a floor that keeps tiny
|
|
10
|
+
# confirm dialogs from collapsing and a cap to stay on-screen.
|
|
11
|
+
SIZE_CLASSES = {
|
|
12
|
+
sm: "w-full max-w-md",
|
|
13
|
+
md: "w-full max-w-xl",
|
|
14
|
+
lg: "w-full max-w-2xl",
|
|
15
|
+
xl: "w-full max-w-4xl",
|
|
16
|
+
auto: "w-fit max-w-[90vw] min-w-[400px]",
|
|
17
|
+
full: "w-full max-w-[95vw]"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
7
20
|
protected
|
|
8
21
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
# Surface (bg, border, radius, backdrop) lives in `.pu-dialog` so
|
|
23
|
+
# the centered modal, dirty-form-guard prompt, and Turbo confirm
|
|
24
|
+
# can't drift on design tokens. The remaining utilities are
|
|
25
|
+
# positioning, sizing, and the open/close transform animation —
|
|
26
|
+
# driven by [data-open] (set on the frame after showModal() by
|
|
27
|
+
# remote_modal_controller); avoids the @starting-style spec dance.
|
|
28
|
+
def base_dialog_classes
|
|
29
|
+
"pu-dialog " \
|
|
13
30
|
"top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
|
|
14
31
|
"max-h-[80vh] " \
|
|
15
|
-
"
|
|
16
|
-
"opacity-0 open:opacity-100
|
|
32
|
+
"open:flex flex-col p-0 " \
|
|
33
|
+
"opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
|
|
34
|
+
"transition-[opacity,transform] duration-200 ease-out"
|
|
17
35
|
end
|
|
18
36
|
end
|
|
19
37
|
end
|
|
@@ -4,21 +4,36 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Modal
|
|
6
6
|
class Slideover < Plutonium::UI::Modal::Base
|
|
7
|
+
# Width tokens for each VALID_SIZES key. Mobile always takes
|
|
8
|
+
# full width (the slideover is pinned to the right edge, so
|
|
9
|
+
# anything narrower than the viewport looks awkward on phones);
|
|
10
|
+
# the `sm:` token controls the desktop width. `:md` reproduces
|
|
11
|
+
# the historical default. `:auto` switches to `sm:w-auto` with
|
|
12
|
+
# a viewport cap so the panel grows to fit the form.
|
|
13
|
+
SIZE_CLASSES = {
|
|
14
|
+
sm: "w-full sm:w-[400px]",
|
|
15
|
+
md: "w-full sm:w-[480px]",
|
|
16
|
+
lg: "w-full sm:w-[640px]",
|
|
17
|
+
xl: "w-full sm:w-[800px]",
|
|
18
|
+
auto: "w-full sm:w-auto sm:max-w-[90vw] sm:min-w-[480px]",
|
|
19
|
+
full: "w-full sm:w-[95vw]"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
7
22
|
protected
|
|
8
23
|
|
|
9
|
-
|
|
10
|
-
|
|
24
|
+
# Animation is driven by `data-open`, toggled by the remote-modal
|
|
25
|
+
# controller on the frame after showModal(). Mirrors the filter
|
|
26
|
+
# slideover's pattern — see Centered for the same rationale.
|
|
27
|
+
def base_dialog_classes
|
|
28
|
+
"fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
|
|
11
29
|
"bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
|
|
12
|
-
"backdrop:bg-
|
|
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 " \
|
|
13
33
|
"rounded-none p-0 " \
|
|
14
|
-
"
|
|
15
|
-
"translate-x-full open:translate-x-0 " \
|
|
16
|
-
"transition-
|
|
17
|
-
"[transition-behavior:allow-discrete] " \
|
|
18
|
-
"starting:open:translate-x-full " \
|
|
19
|
-
"backdrop:transition-[display,overlay,background-color] backdrop:duration-300 " \
|
|
20
|
-
"backdrop:[transition-behavior:allow-discrete] " \
|
|
21
|
-
"starting:open:backdrop:bg-transparent"
|
|
34
|
+
"open:flex flex-col " \
|
|
35
|
+
"translate-x-full data-[open]:translate-x-0 " \
|
|
36
|
+
"transition-transform duration-300 ease-out"
|
|
22
37
|
end
|
|
23
38
|
end
|
|
24
39
|
end
|
|
@@ -52,10 +52,11 @@ module Plutonium
|
|
|
52
52
|
|
|
53
53
|
slot :section, Section, collection: true
|
|
54
54
|
|
|
55
|
-
def initialize(email:, name: nil, avatar_url: nil)
|
|
55
|
+
def initialize(email:, name: nil, avatar_url: nil, record: nil)
|
|
56
56
|
@email = email
|
|
57
57
|
@name = name
|
|
58
58
|
@avatar_url = avatar_url
|
|
59
|
+
@record = record
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def view_template
|
|
@@ -68,14 +69,6 @@ module Plutonium
|
|
|
68
69
|
private
|
|
69
70
|
|
|
70
71
|
def render_trigger_button
|
|
71
|
-
if @avatar_url.present?
|
|
72
|
-
render_avatar_button
|
|
73
|
-
else
|
|
74
|
-
render_default_button
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def render_avatar_button
|
|
79
72
|
button(
|
|
80
73
|
type: "button",
|
|
81
74
|
class: "flex mx-3 text-sm rounded-full md:mr-0 focus:ring-2 focus:ring-[var(--pu-border)] focus:ring-offset-2 transition-shadow",
|
|
@@ -84,20 +77,7 @@ module Plutonium
|
|
|
84
77
|
data: {resource_drop_down_target: "trigger"}
|
|
85
78
|
) do
|
|
86
79
|
span(class: "sr-only") { "Open user menu" }
|
|
87
|
-
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def render_default_button
|
|
92
|
-
button(
|
|
93
|
-
type: "button",
|
|
94
|
-
class: "flex mx-3 p-1 text-sm border border-[var(--pu-border)] text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-full md:mr-0 focus:ring-2 focus:ring-[var(--pu-border)] focus:ring-offset-2 transition-colors",
|
|
95
|
-
aria: {expanded: "false"},
|
|
96
|
-
id: "user-nav-dropdown-toggle",
|
|
97
|
-
data: {resource_drop_down_target: "trigger"}
|
|
98
|
-
) do
|
|
99
|
-
span(class: "sr-only") { "Open user menu" }
|
|
100
|
-
render Phlex::TablerIcons::User.new(class: "w-6 h-6")
|
|
80
|
+
Avatar(@record, src: @avatar_url, size: :sm, alt: "avatar", class: "ring-2 ring-[var(--pu-border)]")
|
|
101
81
|
end
|
|
102
82
|
end
|
|
103
83
|
|
|
@@ -7,7 +7,7 @@ module Plutonium
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def page_title
|
|
10
|
-
current_definition.edit_page_title || super || "Edit"
|
|
10
|
+
current_definition.edit_page_title || super || "Edit #{resource_name(resource_class, 1)}"
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def page_description
|
|
@@ -23,9 +23,12 @@ module Plutonium
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def render_modal_form
|
|
26
|
-
modal_class = (current_definition.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
modal_class = Plutonium::UI::Modal::Base.class_for_mode(current_definition.modal_mode)
|
|
27
|
+
render modal_class.new(
|
|
28
|
+
title: page_title,
|
|
29
|
+
description: page_description,
|
|
30
|
+
size: current_definition.modal_size
|
|
31
|
+
) do
|
|
29
32
|
render partial("resource_form")
|
|
30
33
|
end
|
|
31
34
|
end
|
|
@@ -18,12 +18,14 @@ module Plutonium
|
|
|
18
18
|
|
|
19
19
|
def render_default_content
|
|
20
20
|
if in_modal?
|
|
21
|
-
modal_class = (
|
|
22
|
-
|
|
21
|
+
modal_class = Plutonium::UI::Modal::Base.class_for_mode(
|
|
22
|
+
current_interactive_action.modal_mode(current_definition)
|
|
23
|
+
)
|
|
23
24
|
|
|
24
25
|
render modal_class.new(
|
|
25
26
|
title: page_title,
|
|
26
|
-
description: page_description
|
|
27
|
+
description: page_description,
|
|
28
|
+
size: current_interactive_action.modal_size(current_definition)
|
|
27
29
|
) do
|
|
28
30
|
render partial("interactive_action_form")
|
|
29
31
|
end
|
|
@@ -7,7 +7,7 @@ module Plutonium
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def page_title
|
|
10
|
-
current_definition.new_page_title || super || "New"
|
|
10
|
+
current_definition.new_page_title || super || "New #{resource_name(resource_class, 1)}"
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def page_description
|
|
@@ -23,9 +23,12 @@ module Plutonium
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def render_modal_form
|
|
26
|
-
modal_class = (current_definition.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
modal_class = Plutonium::UI::Modal::Base.class_for_mode(current_definition.modal_mode)
|
|
27
|
+
render modal_class.new(
|
|
28
|
+
title: page_title,
|
|
29
|
+
description: page_description,
|
|
30
|
+
size: current_definition.modal_size
|
|
31
|
+
) do
|
|
29
32
|
render partial("resource_form")
|
|
30
33
|
end
|
|
31
34
|
end
|
|
@@ -14,10 +14,7 @@ module Plutonium
|
|
|
14
14
|
def initialize(*, query_object:, search_url:, search_param: :q, search_value: nil, attributes: {}, **opts, &)
|
|
15
15
|
opts[:as] = :q
|
|
16
16
|
opts[:method] = :get
|
|
17
|
-
attributes = attributes.deep_merge(
|
|
18
|
-
id: "filter-form",
|
|
19
|
-
data: {turbo_frame: nil}
|
|
20
|
-
)
|
|
17
|
+
attributes = attributes.deep_merge(data: {turbo_frame: nil})
|
|
21
18
|
super(*, attributes:, **opts, &)
|
|
22
19
|
@query_object = query_object
|
|
23
20
|
@search_url = search_url
|
|
@@ -25,6 +22,17 @@ module Plutonium
|
|
|
25
22
|
@search_value = search_value
|
|
26
23
|
end
|
|
27
24
|
|
|
25
|
+
# The Filters slideover renders on every index page (off-screen
|
|
26
|
+
# until opened) — same DOM as any CRUD modal that might appear.
|
|
27
|
+
# Base defaults the form id to "resource-form"; without this
|
|
28
|
+
# override, document-level `turbo_stream.replace("resource-form", …)`
|
|
29
|
+
# from a CRUD submit would clobber the wrong form. Pick a distinct
|
|
30
|
+
# id so the two never collide.
|
|
31
|
+
def initialize_attributes
|
|
32
|
+
super
|
|
33
|
+
attributes[:id] = "filter-form"
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
def form_class
|
|
29
37
|
"flex-1 flex flex-col min-h-0"
|
|
30
38
|
end
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radioactive-labs/plutonium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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",
|
|
@@ -30,15 +30,18 @@
|
|
|
30
30
|
"marked": "^15.0.3"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"@tabler/icons-vue": "^3.44.0",
|
|
33
34
|
"@tailwindcss/forms": "^0.5.10",
|
|
34
35
|
"@tailwindcss/postcss": "^4.3.0",
|
|
35
36
|
"@tailwindcss/typography": "^0.5.16",
|
|
37
|
+
"asciinema-player": "^3.15.1",
|
|
36
38
|
"chokidar-cli": "^3.0.0",
|
|
37
39
|
"concurrently": "^8.2.2",
|
|
38
40
|
"cssnano": "^7.0.2",
|
|
39
41
|
"esbuild": "^0.28.0",
|
|
40
42
|
"esbuild-plugin-manifest": "^1.0.3",
|
|
41
43
|
"flowbite-typography": "^1.0.5",
|
|
44
|
+
"medium-zoom": "^1.1.0",
|
|
42
45
|
"mermaid": "^11.15.0",
|
|
43
46
|
"postcss": "^8.5.14",
|
|
44
47
|
"postcss-cli": "^11.0.1",
|
data/src/css/components.css
CHANGED
|
@@ -428,13 +428,21 @@
|
|
|
428
428
|
=================== */
|
|
429
429
|
|
|
430
430
|
.pu-checkbox {
|
|
431
|
-
@apply size-5 rounded-md bg-white border-2 border-slate-300 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 indeterminate:bg-primary-600 indeterminate:border-primary-600 hover:border-primary-400;
|
|
431
|
+
@apply size-5 rounded-md bg-white border-2 border-slate-300 text-primary-600 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 indeterminate:bg-primary-600 indeterminate:border-primary-600 hover:border-primary-400;
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
.dark .pu-checkbox {
|
|
435
435
|
@apply bg-slate-700 border-slate-500 hover:border-primary-400;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
.pu-radio {
|
|
439
|
+
@apply size-5 rounded-full bg-white border-2 border-slate-300 text-primary-600 accent-primary-600 focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 cursor-pointer transition-all duration-150 checked:bg-primary-600 checked:border-primary-600 hover:border-primary-400;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.dark .pu-radio {
|
|
443
|
+
@apply bg-slate-700 border-slate-500 hover:border-primary-400;
|
|
444
|
+
}
|
|
445
|
+
|
|
438
446
|
/* ===================
|
|
439
447
|
EMPTY STATE
|
|
440
448
|
=================== */
|
|
@@ -486,6 +494,35 @@
|
|
|
486
494
|
@apply w-14 px-6 py-4;
|
|
487
495
|
}
|
|
488
496
|
|
|
497
|
+
/* ===================
|
|
498
|
+
DIALOG — shared centered <dialog> surface
|
|
499
|
+
===================
|
|
500
|
+
Visual tokens reused by Modal::Centered, the dirty-form-guard
|
|
501
|
+
confirm dialog, and Turbo's themed confirm. Keeps bg/border/radius
|
|
502
|
+
and backdrop styling in one place so a design-token change can't
|
|
503
|
+
silently drift across the three. Animation is driven by toggling
|
|
504
|
+
the [data-open] attribute one frame after showModal() — see
|
|
505
|
+
remote_modal_controller for the rationale (the @starting-style /
|
|
506
|
+
allow-discrete spec dance is unreliable across browsers).
|
|
507
|
+
Positioning, size, and the dialog's own opacity/scale transition
|
|
508
|
+
stay on the call site; they vary per dialog. Slideover has its
|
|
509
|
+
own pinned-right surface and intentionally does not opt in. */
|
|
510
|
+
.pu-dialog {
|
|
511
|
+
background-color: var(--pu-surface);
|
|
512
|
+
border: 1px solid var(--pu-border);
|
|
513
|
+
border-radius: var(--pu-radius-lg);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.pu-dialog::backdrop {
|
|
517
|
+
background-color: transparent;
|
|
518
|
+
transition: background-color 200ms ease-out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.pu-dialog[data-open]::backdrop {
|
|
522
|
+
background-color: rgb(0 0 0 / 0.6);
|
|
523
|
+
backdrop-filter: blur(4px);
|
|
524
|
+
}
|
|
525
|
+
|
|
489
526
|
/* ===================
|
|
490
527
|
ICON RAIL — modern shell
|
|
491
528
|
=================== */
|
data/src/css/slim_select.css
CHANGED
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
@apply !hidden;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/*
|
|
37
|
+
/* Sized to match `.pu-input` so selects line up with text/date/email
|
|
38
|
+
inputs in the same row. */
|
|
38
39
|
.ss-main {
|
|
39
|
-
@apply flex flex-row items-center relative select-none w-full px-
|
|
40
|
+
@apply flex flex-row items-center relative select-none w-full px-3 h-9 cursor-pointer border text-sm outline-none transition-colors duration-200 overflow-hidden focus:ring-2;
|
|
40
41
|
background-color: var(--pu-input-bg);
|
|
41
42
|
border-color: var(--pu-input-border);
|
|
42
43
|
border-radius: var(--pu-radius-md);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="dirty-form-guard"
|
|
4
|
+
// Prompts before dismissing a modal form whose contents have changed.
|
|
5
|
+
// Self-disables when not inside a <dialog>, so it's safe to attach to
|
|
6
|
+
// every form unconditionally.
|
|
7
|
+
//
|
|
8
|
+
// Esc is intercepted at the document's capture phase: relying on the
|
|
9
|
+
// dialog's `cancel` event alone proved flaky under rapid/held Esc when
|
|
10
|
+
// the parent dialog uses `closedby="any"`. The cancel listener stays
|
|
11
|
+
// as defense in depth.
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
static targets = ["confirmDialog"];
|
|
14
|
+
|
|
15
|
+
// Set by controllers, not the user — comparing them would flag
|
|
16
|
+
// every form as dirty on connect (return_to) or on submit (pre_submit).
|
|
17
|
+
static IGNORED_KEYS = new Set(["authenticity_token", "return_to", "pre_submit"]);
|
|
18
|
+
|
|
19
|
+
connect() {
|
|
20
|
+
this.dialog = this.element.closest("dialog");
|
|
21
|
+
if (!this.dialog) return;
|
|
22
|
+
|
|
23
|
+
this.snapshot = this.#serialize();
|
|
24
|
+
this.forceClose = false;
|
|
25
|
+
this.submitting = false;
|
|
26
|
+
|
|
27
|
+
this.onCancel = this.#onCancel.bind(this);
|
|
28
|
+
this.onSubmit = this.#onSubmit.bind(this);
|
|
29
|
+
this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
|
|
30
|
+
this.onConfirmCancel = this.#onConfirmCancel.bind(this);
|
|
31
|
+
this.onKeydown = this.#onKeydown.bind(this);
|
|
32
|
+
|
|
33
|
+
document.addEventListener("keydown", this.onKeydown, true);
|
|
34
|
+
// Capture phase so this runs before remote-modal's cancel handler
|
|
35
|
+
// — that way `defaultPrevented` is visible there if we intervene.
|
|
36
|
+
this.dialog.addEventListener("cancel", this.onCancel, true);
|
|
37
|
+
|
|
38
|
+
this.element.addEventListener("submit", this.onSubmit);
|
|
39
|
+
this.#closeButtons().forEach((btn) =>
|
|
40
|
+
btn.addEventListener("click", this.onCloseButtonClick, true),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (this.hasConfirmDialogTarget) {
|
|
44
|
+
this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
disconnect() {
|
|
49
|
+
if (!this.dialog) return;
|
|
50
|
+
document.removeEventListener("keydown", this.onKeydown, true);
|
|
51
|
+
this.dialog.removeEventListener("cancel", this.onCancel, true);
|
|
52
|
+
this.element.removeEventListener("submit", this.onSubmit);
|
|
53
|
+
this.#closeButtons().forEach((btn) =>
|
|
54
|
+
btn.removeEventListener("click", this.onCloseButtonClick, true),
|
|
55
|
+
);
|
|
56
|
+
if (this.hasConfirmDialogTarget) {
|
|
57
|
+
this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async discard() {
|
|
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.
|
|
66
|
+
this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
keepEditing() {
|
|
70
|
+
this.#closeConfirm();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#closeButtons() {
|
|
74
|
+
if (!this.dialog) return [];
|
|
75
|
+
return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#serialize() {
|
|
79
|
+
const data = new FormData(this.element);
|
|
80
|
+
const enc = encodeURIComponent;
|
|
81
|
+
return [...data.entries()]
|
|
82
|
+
.filter(([key]) => !this.constructor.IGNORED_KEYS.has(key))
|
|
83
|
+
.map(([key, value]) => {
|
|
84
|
+
const v = value instanceof File ? value.name : value;
|
|
85
|
+
return `${enc(key)}=${enc(v)}`;
|
|
86
|
+
})
|
|
87
|
+
.sort()
|
|
88
|
+
.join("&");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#isDirty() {
|
|
92
|
+
return this.#serialize() !== this.snapshot;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#onSubmit() {
|
|
96
|
+
this.submitting = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#confirmIsOpen() {
|
|
100
|
+
return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#onKeydown(event) {
|
|
104
|
+
if (event.key !== "Escape") return;
|
|
105
|
+
if (!this.dialog.open) return;
|
|
106
|
+
|
|
107
|
+
// Once the confirm is open, only its buttons may close it.
|
|
108
|
+
if (this.#confirmIsOpen()) {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
event.stopImmediatePropagation();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.forceClose || this.submitting) return;
|
|
116
|
+
if (!this.#isDirty()) return;
|
|
117
|
+
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
event.stopImmediatePropagation();
|
|
121
|
+
this.#promptDiscard();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#onCancel(event) {
|
|
125
|
+
if (this.forceClose || this.submitting) return;
|
|
126
|
+
if (!this.#isDirty()) return;
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
this.#promptDiscard();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#onCloseButtonClick(event) {
|
|
132
|
+
if (this.forceClose || this.submitting) return;
|
|
133
|
+
if (!this.#isDirty()) return;
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
this.#promptDiscard();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#onConfirmCancel(event) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#promptDiscard() {
|
|
144
|
+
if (this.hasConfirmDialogTarget) {
|
|
145
|
+
const d = this.confirmDialogTarget;
|
|
146
|
+
d.showModal();
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
requestAnimationFrame(() => d.setAttribute("data-open", ""));
|
|
149
|
+
});
|
|
150
|
+
} else if (window.confirm("Discard your changes?")) {
|
|
151
|
+
this.forceClose = true;
|
|
152
|
+
this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #closeConfirm() {
|
|
157
|
+
if (!this.hasConfirmDialogTarget) return;
|
|
158
|
+
const d = this.confirmDialogTarget;
|
|
159
|
+
if (!d.open) return;
|
|
160
|
+
d.removeAttribute("data-open");
|
|
161
|
+
const animations = d.getAnimations({ subtree: true });
|
|
162
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
163
|
+
d.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -6,19 +6,20 @@ export default class extends Controller {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
preSubmit() {
|
|
9
|
-
//
|
|
9
|
+
// Some widgets (e.g. slim-select) dispatch their own change event on top
|
|
10
|
+
// of the native one, so this can fire twice per user action. Remove any
|
|
11
|
+
// prior hidden field before appending a fresh one to avoid duplicates.
|
|
12
|
+
this.element.querySelectorAll('input[name="pre_submit"]').forEach(n => n.remove());
|
|
13
|
+
|
|
10
14
|
const hiddenField = document.createElement('input');
|
|
11
15
|
hiddenField.type = 'hidden';
|
|
12
16
|
hiddenField.name = 'pre_submit';
|
|
13
17
|
hiddenField.value = 'true';
|
|
14
|
-
|
|
15
|
-
// Append it to the form
|
|
16
18
|
this.element.appendChild(hiddenField);
|
|
17
19
|
|
|
18
20
|
// Skip validation by setting novalidate attribute
|
|
19
21
|
this.element.setAttribute('novalidate', '');
|
|
20
22
|
|
|
21
|
-
// Submit the form
|
|
22
23
|
this.submit();
|
|
23
24
|
}
|
|
24
25
|
|