studio-engine 0.130.0 → 0.131.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/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +20 -2
|
@@ -19,6 +19,10 @@ module Studio
|
|
|
19
19
|
module MagicLinkIssuing
|
|
20
20
|
extend ActiveSupport::Concern
|
|
21
21
|
|
|
22
|
+
# Shown when a link could not be minted or mailed. The same words for every
|
|
23
|
+
# address, known or new, so a failure reveals no more than a success does.
|
|
24
|
+
DELIVERY_FAILED = "We could not send your sign-in email just now. Please try again in a few minutes.".freeze
|
|
25
|
+
|
|
22
26
|
private
|
|
23
27
|
|
|
24
28
|
# Mint a token. `return_to` is sanitized to a same-origin path by the store.
|
|
@@ -20,11 +20,24 @@ class MagicLinksController < ApplicationController
|
|
|
20
20
|
# Respond uniformly for any well-formed email. Under create-or-login every
|
|
21
21
|
# address is "valid" (it logs in or signs up), so there is nothing to
|
|
22
22
|
# enumerate. A malformed email gets the same response with no mail sent.
|
|
23
|
+
#
|
|
24
|
+
# A mint or mail failure is logged once (rescue_and_log) and answered the same
|
|
25
|
+
# way for every address, as the passwordless signup does: the login page with
|
|
26
|
+
# one alert, or a 503 with a fixed JSON body. 503, not 4xx: the visitor's
|
|
27
|
+
# input was fine and a retry may work; a 4xx would tell a client to change the
|
|
28
|
+
# request. The body never carries the internal message, and this action never
|
|
29
|
+
# looks an account up, so the reply cannot say whether the email is known.
|
|
23
30
|
def create
|
|
24
31
|
email = params[:email].to_s.strip.downcase
|
|
25
32
|
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
begin
|
|
34
|
+
rescue_and_log do
|
|
35
|
+
token = issue_magic_link(email, Studio::LinkToken.sanitize_path(params[:return_to]))
|
|
36
|
+
Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError
|
|
39
|
+
return delivery_failed
|
|
40
|
+
end
|
|
28
41
|
end
|
|
29
42
|
respond_to do |format|
|
|
30
43
|
format.json { render json: { success: true } }
|
|
@@ -32,6 +45,16 @@ class MagicLinksController < ApplicationController
|
|
|
32
45
|
end
|
|
33
46
|
end
|
|
34
47
|
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def delivery_failed
|
|
51
|
+
message = Studio::MagicLinkIssuing::DELIVERY_FAILED
|
|
52
|
+
respond_to do |format|
|
|
53
|
+
format.json { render json: { success: false, error: "delivery_failed", message: message }, status: :service_unavailable }
|
|
54
|
+
format.html { redirect_to login_path, alert: message }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
# issue_magic_link (mint a Studio::Link row) comes from
|
|
36
59
|
# Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
|
|
37
60
|
# consumes it — so the mint and its landing URL cannot drift apart.
|
|
@@ -3,40 +3,76 @@ class RegistrationsController < ApplicationController
|
|
|
3
3
|
|
|
4
4
|
skip_before_action :require_authentication
|
|
5
5
|
|
|
6
|
+
# Shown when the sign-in link could not be minted or mailed. The same words for
|
|
7
|
+
# every address, known or new, so a failure reveals no more than a success does.
|
|
8
|
+
# POST /magic_link shows the same words (Studio::MagicLinkIssuing).
|
|
9
|
+
DELIVERY_FAILED = Studio::MagicLinkIssuing::DELIVERY_FAILED
|
|
10
|
+
|
|
6
11
|
def new
|
|
7
12
|
@user = User.new
|
|
8
13
|
end
|
|
9
14
|
|
|
15
|
+
# Gated on the same predicate registrations/new renders the password form on,
|
|
16
|
+
# so the form a visitor sees is always the form this action reads.
|
|
17
|
+
#
|
|
18
|
+
# No method-level rescue. One used to catch every StandardError here, log
|
|
19
|
+
# nothing, and render registrations/new; on the passwordless branch @user was
|
|
20
|
+
# never set, so that render raised NoMethodError on nil and the failure that
|
|
21
|
+
# started it was lost. Each branch now owns its failure, and anything it does
|
|
22
|
+
# not expect is logged by rescue_and_log and left to surface.
|
|
10
23
|
def create
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
Studio.password_login_available? ? create_with_password : create_passwordless
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Passwordless apps: there is no create-account-by-form. Logging a user in
|
|
30
|
+
# straight from a signup POST would skip proof of email ownership, so we
|
|
31
|
+
# treat "sign up" as a magic-link request — the create-or-login link (which
|
|
32
|
+
# only fires after the recipient clicks it) does the account creation.
|
|
33
|
+
#
|
|
34
|
+
# A mint or mail failure is logged (rescue_and_log) and answered with the same
|
|
35
|
+
# friendly page for every address. No account exists either way, and this
|
|
36
|
+
# branch never looks one up, so the answer cannot say whether the email is known.
|
|
37
|
+
def create_passwordless
|
|
38
|
+
email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
|
|
39
|
+
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
40
|
+
begin
|
|
41
|
+
rescue_and_log do
|
|
42
|
+
token = issue_magic_link(email, nil)
|
|
43
|
+
Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError
|
|
46
|
+
return redirect_to signup_path, alert: DELIVERY_FAILED
|
|
22
47
|
end
|
|
23
|
-
return redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}."
|
|
24
48
|
end
|
|
49
|
+
redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}."
|
|
50
|
+
end
|
|
25
51
|
|
|
52
|
+
# A validation failure is the visitor's input: re-render the form with its
|
|
53
|
+
# errors and write no ErrorLog row. Anything else is logged and re-raised to
|
|
54
|
+
# the engine's catch-all, never swallowed.
|
|
55
|
+
#
|
|
56
|
+
# No `target:` here. The user is unsaved when the raise lands, and ErrorLog's
|
|
57
|
+
# belongs_to would autosave it while writing the row: the same failing write a
|
|
58
|
+
# second time, inside the logger. That raise escapes before @_error_logged is
|
|
59
|
+
# set, so the catch-all logs the same failure again: two rows for one error.
|
|
60
|
+
def create_with_password
|
|
26
61
|
@user = User.new(user_params)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@user.save
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
rescue_and_log do
|
|
63
|
+
Studio.configure_new_user.call(@user)
|
|
64
|
+
if @user.save
|
|
65
|
+
set_app_session(@user)
|
|
66
|
+
redirect_to root_path, notice: Studio.welcome_message.call(@user)
|
|
67
|
+
else
|
|
68
|
+
render :new, status: :unprocessable_entity
|
|
69
|
+
end
|
|
32
70
|
end
|
|
33
|
-
rescue StandardError => e
|
|
34
|
-
render :new, status: :unprocessable_entity
|
|
35
71
|
end
|
|
36
72
|
|
|
37
|
-
|
|
38
|
-
|
|
73
|
+
# A POST with no `user` key builds a blank user, whose validations then
|
|
74
|
+
# re-render the form, rather than a ParameterMissing the form cannot show.
|
|
39
75
|
def user_params
|
|
40
|
-
params.
|
|
76
|
+
params.fetch(:user, {}).permit(*Studio.registration_params)
|
|
41
77
|
end
|
|
42
78
|
end
|
|
@@ -19,4 +19,10 @@ class StyleController < ApplicationController
|
|
|
19
19
|
rescue StandardError => e
|
|
20
20
|
Rails.logger.warn("[style] theme preload skipped: #{e.message}")
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# One page of the Loaders section's demo feed, at /admin/style/loaders_feed?page=N
|
|
24
|
+
# (style/_loaders_feed_page; Studio::Loader::DEMO_FEED_PAGES pages).
|
|
25
|
+
def loaders_feed
|
|
26
|
+
@page = params[:page].to_i.clamp(1, Studio::Loader::DEMO_FEED_PAGES)
|
|
27
|
+
end
|
|
22
28
|
end
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
|
-
# The agent chat's voice option (docs/AGENT_CHAT.md, "Talking by voice")
|
|
4
|
+
# The agent chat's voice option (docs/AGENT_CHAT.md, "Talking by voice"), and
|
|
5
|
+
# the link that opens the talk-to-agent modal ("Talk to an agent in a modal").
|
|
5
6
|
module AgentChatHelper
|
|
7
|
+
# A link that opens studio/agent_chat/_talk_modal with `soul`, `topic` and
|
|
8
|
+
# an optional `opener` (the visitor's first message; default "I'd like to
|
|
9
|
+
# talk about <topic>."). `href` is where the link goes when the modal is not
|
|
10
|
+
# on the page, did not load, or the click is a modified one (a new tab): a
|
|
11
|
+
# real chat page, so no click is ever lost. A block replaces `label`.
|
|
12
|
+
def studio_talk_to_agent_link(label = nil, href:, soul:, topic: nil, opener: nil, **options, &block)
|
|
13
|
+
data = (options.delete(:data) || {}).merge(studio_talk_to_agent: true, talk_soul: soul.to_s,
|
|
14
|
+
talk_topic: topic.to_s.presence, talk_opener: opener.to_s.presence).compact
|
|
15
|
+
options[:aria] = { haspopup: "dialog" }.merge(options[:aria] || {})
|
|
16
|
+
block ? link_to(href, **options, data: data, &block) : link_to(label, href, **options, data: data)
|
|
17
|
+
end
|
|
18
|
+
|
|
6
19
|
# The signed-in user on this page, or nil. A page whose controller has no
|
|
7
20
|
# current_user is a signed-out page.
|
|
8
21
|
def studio_agent_chat_viewer
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The crumb trail for studio/_page_header, from the app's parent map (Studio::Crumbs,
|
|
5
|
+
# docs/PAGE_HEADER.md). Prefixed for the reason Studio::GeoHelper gives: every helper
|
|
6
|
+
# module is included into every view.
|
|
7
|
+
#
|
|
8
|
+
# studio_crumbs(:deployments) the map's own page: its ancestors
|
|
9
|
+
# studio_crumbs(parent: :deployments) a page not in the map: its parent and up
|
|
10
|
+
# studio_crumbs(:release, release: @release) keywords for the map's lambdas
|
|
11
|
+
# studio_crumbs_here an engine page: the map's page at this address
|
|
12
|
+
#
|
|
13
|
+
# A key the map lacks raises in development and test, and in production logs and
|
|
14
|
+
# draws no trail (Studio::Crumbs.strict?), so a typo is not a 500.
|
|
15
|
+
module CrumbsHelper
|
|
16
|
+
def studio_crumbs(key = nil, parent: nil, **locals)
|
|
17
|
+
Studio.crumbs.trail(self, key, parent: parent, **locals)
|
|
18
|
+
rescue Studio::Crumbs::UnknownPage, Studio::Crumbs::Cycle => error
|
|
19
|
+
raise if Studio::Crumbs.strict?
|
|
20
|
+
|
|
21
|
+
Studio::Crumbs.report(error)
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The trail for the page at this request's address, from the host's map, or none. An
|
|
26
|
+
# engine page cannot know the host's key for itself, so it asks by address.
|
|
27
|
+
def studio_crumbs_here
|
|
28
|
+
return [] unless respond_to?(:request) && request
|
|
29
|
+
|
|
30
|
+
key = Studio.crumbs.key_for_path(self, request.path)
|
|
31
|
+
key ? studio_crumbs(key) : []
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -13,6 +13,12 @@ module Studio
|
|
|
13
13
|
# <%= studio_loader_pending %> inside a submit button
|
|
14
14
|
# <%= studio_loader_overlay %> the page-load overlay (layout, once)
|
|
15
15
|
#
|
|
16
|
+
# The plain styles draw no icon, only theme colours (docs/LOADER.md, "The family"):
|
|
17
|
+
#
|
|
18
|
+
# <%= studio_loader style: :spinner %> a ring, 20px
|
|
19
|
+
# <%= studio_loader style: :dots %> three pulsing dots
|
|
20
|
+
# <%= studio_loader style: :bar, percent: 40 %> a bar; without percent it sweeps
|
|
21
|
+
#
|
|
16
22
|
# Prefixed names for the reason Studio::GeoHelper gives: every helper module is
|
|
17
23
|
# included into every view.
|
|
18
24
|
module LoaderHelper
|
|
@@ -22,7 +28,7 @@ module Studio
|
|
|
22
28
|
LOADER_BEAD_LAYERS = [[46, 9, 0.5], [30, 9, 0.75], [16, 9, 0.95], [7, 9.5, 1], [2.6, 10, 1], [0.3, 17, 1]].freeze
|
|
23
29
|
LOADER_BEAD_UNIT = 264.3
|
|
24
30
|
|
|
25
|
-
def studio_loader(size:
|
|
31
|
+
def studio_loader(size: nil, style: nil, icon: nil, art: nil, label: "Loading", percent: nil,
|
|
26
32
|
tone: nil, pending: false, **html)
|
|
27
33
|
config = Studio.loader
|
|
28
34
|
style = style ? Studio::Loader.style!(style) : config[:style]
|
|
@@ -34,7 +40,7 @@ module Studio
|
|
|
34
40
|
|
|
35
41
|
render "studio/loader/loader",
|
|
36
42
|
style: style,
|
|
37
|
-
size: studio_loader_size(size),
|
|
43
|
+
size: studio_loader_size(size.nil? ? Studio::Loader.default_size(style) : size),
|
|
38
44
|
icon: icon || Studio.loader_icon,
|
|
39
45
|
art: art_data,
|
|
40
46
|
label: label,
|
|
@@ -64,6 +70,77 @@ module Studio
|
|
|
64
70
|
render "studio/loader/overlay", label: label, delay: delay.to_i, min_show: min_show.to_i, max_wait: max_wait.to_i, size: size, options: options
|
|
65
71
|
end
|
|
66
72
|
|
|
73
|
+
# ---- The rest of the family: the page's shape while it waits ----------
|
|
74
|
+
|
|
75
|
+
SKELETON_KINDS = %i[lines avatar card media].freeze
|
|
76
|
+
SKELETON_LINES = { lines: 3, avatar: 2, card: 2, media: 0 }.freeze
|
|
77
|
+
# Line widths, in turn, so a block of lines reads as text; the last is short.
|
|
78
|
+
SKELETON_WIDTHS = [100, 94, 86, 97, 90].freeze
|
|
79
|
+
|
|
80
|
+
# Grey shapes of the content to come (docs/LOADER.md, "Skeleton"). The
|
|
81
|
+
# wrapper is aria-busy; every shape is hidden from assistive tech, and one
|
|
82
|
+
# line of screen-reader text names the wait. Any html option lands on the
|
|
83
|
+
# wrapper, which is a grid: give it a class for the layout of `count` items.
|
|
84
|
+
#
|
|
85
|
+
# <%= studio_skeleton %> three lines
|
|
86
|
+
# <%= studio_skeleton :avatar, count: 4 %> four list rows
|
|
87
|
+
# <%= studio_skeleton :card, count: 3, class: "sm:grid-cols-3" %>
|
|
88
|
+
# <%= studio_skeleton :media %> one 16:9 block
|
|
89
|
+
def studio_skeleton(kind = :lines, count: 1, lines: nil, label: "Loading", **html)
|
|
90
|
+
kind = kind.to_s.to_sym
|
|
91
|
+
unless SKELETON_KINDS.include?(kind)
|
|
92
|
+
raise ArgumentError, "studio_skeleton kind must be one of #{SKELETON_KINDS.map(&:inspect).join(', ')} (got #{kind.inspect})"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
render "studio/loader/skeleton",
|
|
96
|
+
kind: kind,
|
|
97
|
+
count: count.to_i.clamp(1, 50),
|
|
98
|
+
lines: (lines.nil? ? SKELETON_LINES[kind] : lines.to_i.clamp(0, 12)),
|
|
99
|
+
label: label,
|
|
100
|
+
html: html
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The width of line `index` of `total`, as a CSS length.
|
|
104
|
+
def studio_skeleton_width(index, total)
|
|
105
|
+
return "60%" if total > 1 && index == total - 1
|
|
106
|
+
|
|
107
|
+
"#{SKELETON_WIDTHS[index % SKELETON_WIDTHS.size]}%"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A list that loads its next page as its end comes near (docs/LOADER.md,
|
|
111
|
+
# "Infinite scroll"). The block is one page of items; the view renders the
|
|
112
|
+
# same call for every page, with `page` and the next page's URL (nil on the
|
|
113
|
+
# last). Each later page arrives inside a <turbo-frame> nested in the one
|
|
114
|
+
# before, so the list grows in place; a visible "Load more" link loads it
|
|
115
|
+
# by itself, with the keyboard or with no script.
|
|
116
|
+
#
|
|
117
|
+
# <%= studio_infinite_scroll id: "posts", page: @page,
|
|
118
|
+
# next_url: (posts_path(page: @page + 1) if @more) do %>
|
|
119
|
+
# <% @posts.each do |post| %><%= render post %><% end %>
|
|
120
|
+
# <% end %>
|
|
121
|
+
def studio_infinite_scroll(id:, page: 1, next_url: nil, more_label: "Load more",
|
|
122
|
+
end_label: "That is everything", announce: "%{count} more loaded", **html, &block)
|
|
123
|
+
page = [page.to_i, 1].max
|
|
124
|
+
render "studio/loader/infinite_scroll",
|
|
125
|
+
id: id.to_s,
|
|
126
|
+
page: page,
|
|
127
|
+
next_url: next_url.presence,
|
|
128
|
+
more_label: more_label,
|
|
129
|
+
end_label: end_label,
|
|
130
|
+
announce: announce,
|
|
131
|
+
items: capture(&block),
|
|
132
|
+
html: html
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def studio_infinite_scroll_frame_id(id, page) = "#{id}-page-#{page}"
|
|
136
|
+
|
|
137
|
+
# Pull the page down from its top to reload it: phones with touch only
|
|
138
|
+
# (docs/LOADER.md, "Pull to refresh"). Wraps the block. With `frame:` it
|
|
139
|
+
# reloads that <turbo-frame>; without, the page, as a replace visit.
|
|
140
|
+
def studio_pull_to_refresh(frame: nil, label: "Refreshing", **html, &block)
|
|
141
|
+
render "studio/loader/pull_to_refresh", frame: frame, label: label, body: capture(&block), html: html
|
|
142
|
+
end
|
|
143
|
+
|
|
67
144
|
def studio_loader_size(size)
|
|
68
145
|
size.is_a?(Numeric) ? "#{size}px" : size.to_s
|
|
69
146
|
end
|