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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +43 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
  5. data/app/assets/tailwind/studio_engine/engine.css +587 -0
  6. data/app/assets/tailwind/studio_engine/theme.css +4 -0
  7. data/app/controllers/concerns/studio/error_handling.rb +53 -15
  8. data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
  9. data/app/controllers/magic_links_controller.rb +25 -2
  10. data/app/controllers/registrations_controller.rb +58 -22
  11. data/app/controllers/style_controller.rb +6 -0
  12. data/app/helpers/studio/agent_chat_helper.rb +14 -1
  13. data/app/helpers/studio/crumbs_helper.rb +34 -0
  14. data/app/helpers/studio/loader_helper.rb +79 -2
  15. data/app/javascript/studio/agent_chat.js +294 -6
  16. data/app/javascript/studio/carousel.js +99 -0
  17. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  18. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  19. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  20. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  21. data/app/javascript/studio/infinite_scroll.js +142 -0
  22. data/app/javascript/studio/loader.js +6 -1
  23. data/app/javascript/studio/page_transitions.js +65 -8
  24. data/app/javascript/studio/pull_to_refresh.js +147 -0
  25. data/app/javascript/studio/stimulus.js +12 -1
  26. data/app/javascript/studio/talk_to_agent.js +116 -0
  27. data/app/views/components/_geo_signpost.html.erb +2 -2
  28. data/app/views/components/_link_sidebar.html.erb +2 -2
  29. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  30. data/app/views/components/_sidebar_panel.html.erb +1 -1
  31. data/app/views/error_logs/index.html.erb +2 -8
  32. data/app/views/studio/_carousel.html.erb +97 -0
  33. data/app/views/studio/_page_header.html.erb +52 -11
  34. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  35. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  36. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  37. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  38. data/app/views/studio/loader/_loader.html.erb +20 -3
  39. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  40. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  41. data/app/views/style/_loaders.html.erb +118 -0
  42. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  43. data/app/views/style/_modals.html.erb +12 -0
  44. data/app/views/style/_specimen.html.erb +17 -10
  45. data/app/views/style/_tricks.html.erb +64 -2
  46. data/app/views/style/index.html.erb +14 -8
  47. data/app/views/style/loaders_feed.html.erb +7 -0
  48. data/lib/studio/crumbs.rb +150 -0
  49. data/lib/studio/loader.rb +30 -2
  50. data/lib/studio/version.rb +1 -1
  51. data/lib/studio.rb +3 -0
  52. 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
- token = issue_magic_link(email, Studio::LinkToken.sanitize_path(params[:return_to]))
27
- Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
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
- # Passwordless apps: there is no create-account-by-form. Logging a user in
12
- # straight from a signup POST would skip proof of email ownership, so we
13
- # treat "sign up" as a magic-link request — the create-or-login link (which
14
- # only fires after the recipient clicks it) does the account creation.
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
- unless Studio.password_login_available?
18
- email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
19
- if email.match?(URI::MailTo::EMAIL_REGEXP)
20
- token = issue_magic_link(email, nil)
21
- Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
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
- Studio.configure_new_user.call(@user)
28
- rescue_and_log(target: @user) do
29
- @user.save!
30
- set_app_session(@user)
31
- redirect_to root_path, notice: Studio.welcome_message.call(@user)
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
- private
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.require(:user).permit(*Studio.registration_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: 96, style: nil, icon: nil, art: nil, label: "Loading", percent: nil,
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