studio-engine 0.118.0 → 0.119.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.
@@ -0,0 +1,214 @@
1
+ // studio/loader: drives the branded loader's two live states (docs/LOADER.md).
2
+ // The motion itself is CSS (engine-motion.css, "Branded loader"); this module
3
+ // only decides WHEN the page-load overlay shows and marks busy buttons.
4
+ //
5
+ // the overlay [data-studio-loader-overlay], rendered once by
6
+ // studio_loader_overlay. Shown when a visit the USER started (a
7
+ // Turbo advance or restore; never a replace, a refresh, a frame
8
+ // or a prefetch), or a submit of a form marked
9
+ // data-studio-loader-overlay, outlasts the overlay's data-delay
10
+ // ms; hidden when the page arrives. Its role=status label is
11
+ // announced: the overlay never marks itself aria-busy.
12
+ // pending a button holding studio_loader_pending. Turbo marks a
13
+ // submitting form aria-busy itself; for a form Turbo does not
14
+ // drive, the submitter is marked here, and cleared on pagehide
15
+ // and on pageshow (a bfcache restore).
16
+ // the cap a submit Turbo does not drive clears its overlay and button
17
+ // after data-max-wait ms (MAX_WAIT_MS by default): a response that
18
+ // turned into a download replaces no page, so nothing else
19
+ // would. A Turbo visit is never capped; a non-HTML response
20
+ // clears at once, on turbo:reload.
21
+ // the API window.StudioLoader.show(caption) / hide() for any other long
22
+ // wait, and setPercent(el, n) for a loader drawn with percent:.
23
+ //
24
+ // The logic is exported for test/javascript/loader.test.mjs.
25
+
26
+ export var DEFAULT_DELAY_MS = 350
27
+ export var MAX_WAIT_MS = 15000
28
+
29
+ // Only a visit the user started earns the overlay: a click or Turbo.visit
30
+ // (advance) and Back or Forward (restore). A replace is a background refresh
31
+ // (a poll, Turbo 8's page refresh); frames and prefetches never fire turbo:visit.
32
+ export function userVisit(event) {
33
+ var action = event && event.detail && event.detail.action
34
+ return action === 'advance' || action === 'restore'
35
+ }
36
+
37
+ export function overlayDelay(overlay) {
38
+ var value = overlay ? parseInt(overlay.getAttribute('data-delay'), 10) : NaN
39
+ return value >= 0 ? value : DEFAULT_DELAY_MS
40
+ }
41
+
42
+ // The percent a caller handed setPercent, clamped to a number in 0..100.
43
+ export function clampPercent(value) {
44
+ var n = Number(value)
45
+ if (!isFinite(n)) return 0
46
+ return Math.min(100, Math.max(0, n))
47
+ }
48
+
49
+ export function maxWait(overlay) {
50
+ var value = overlay ? parseInt(overlay.getAttribute('data-max-wait'), 10) : NaN
51
+ return value > 0 ? value : MAX_WAIT_MS
52
+ }
53
+
54
+ function findOverlay(doc) {
55
+ return doc.querySelector('[data-studio-loader-overlay]')
56
+ }
57
+
58
+ var pendingTimer = null
59
+ var capTimer = null
60
+ var autoShown = false
61
+
62
+ // The overlay's status is a live region; rewriting its label after the overlay
63
+ // unhides makes a screen reader say it, where text already present when a
64
+ // region appears is often skipped. The label is kept in data-studio-loader-label,
65
+ // so a second show inside the gap restores it rather than the emptied text.
66
+ function announce(overlay, doc) {
67
+ var label = overlay.querySelector('[role="status"] .sr-only')
68
+ if (!label) return
69
+ var text = label.getAttribute('data-studio-loader-label')
70
+ if (text === null) { text = label.textContent; label.setAttribute('data-studio-loader-label', text) }
71
+ label.textContent = ''
72
+ var win = doc.defaultView
73
+ ;(win && win.setTimeout ? win.setTimeout.bind(win) : setTimeout)(function () { label.textContent = text }, 50)
74
+ }
75
+
76
+ export function show(caption, doc) {
77
+ doc = doc || document
78
+ clearTimeout(pendingTimer)
79
+ pendingTimer = null
80
+ autoShown = false
81
+ var overlay = findOverlay(doc)
82
+ if (!overlay) return false
83
+ var line = overlay.querySelector('[data-studio-loader-caption]')
84
+ if (line) line.textContent = caption || ''
85
+ overlay.hidden = false
86
+ announce(overlay, doc)
87
+ // One frame hidden=false at opacity 0, then fade in.
88
+ var raf = (doc.defaultView && doc.defaultView.requestAnimationFrame) || function (fn) { return setTimeout(fn, 16) }
89
+ raf(function () { if (!overlay.hidden) overlay.setAttribute('data-state', 'shown') })
90
+ return true
91
+ }
92
+
93
+ export function hide(doc) {
94
+ doc = doc || document
95
+ clearTimeout(pendingTimer)
96
+ pendingTimer = null
97
+ var overlay = findOverlay(doc)
98
+ if (!overlay) return false
99
+ overlay.removeAttribute('data-state')
100
+ overlay.hidden = true
101
+ return true
102
+ }
103
+
104
+ // Clear the buttons studio/loader marked busy (never one a page marked itself).
105
+ export function clearBusy(doc) {
106
+ doc = doc || document
107
+ clearTimeout(capTimer)
108
+ capTimer = null
109
+ var marked = doc.querySelectorAll('[data-studio-loader-busy]')
110
+ for (var i = 0; i < marked.length; i++) {
111
+ marked[i].removeAttribute('aria-busy')
112
+ marked[i].removeAttribute('data-studio-loader-busy')
113
+ }
114
+ }
115
+
116
+ // Hide the overlay and clear every busy button: the page is ours again.
117
+ export function settle(doc) {
118
+ doc = doc || document
119
+ hide(doc)
120
+ clearBusy(doc)
121
+ }
122
+
123
+ // The cap never hides an overlay a caller showed through StudioLoader.show().
124
+ function armCap(doc) {
125
+ clearTimeout(capTimer)
126
+ capTimer = setTimeout(function () {
127
+ var shownByUs = autoShown
128
+ clearBusy(doc)
129
+ if (shownByUs) hide(doc)
130
+ }, maxWait(findOverlay(doc)))
131
+ }
132
+
133
+ // Show after the overlay's delay, so a fast load never flashes it. Uncapped: a
134
+ // slow Turbo visit keeps its overlay until the page arrives.
135
+ export function showSoon(caption, doc) {
136
+ doc = doc || document
137
+ var overlay = findOverlay(doc)
138
+ if (!overlay || !overlay.hidden) return
139
+ clearTimeout(pendingTimer)
140
+ pendingTimer = setTimeout(function () { show(caption, doc); autoShown = true }, overlayDelay(overlay))
141
+ }
142
+
143
+ export function markBusy(button, doc) {
144
+ doc = doc || document
145
+ button.setAttribute('aria-busy', 'true')
146
+ button.setAttribute('data-studio-loader-busy', '')
147
+ armCap(doc)
148
+ }
149
+
150
+ export function setPercent(el, value) {
151
+ if (!el) return
152
+ var loader = el.classList && el.classList.contains('studio-loader') ? el : el.querySelector('.studio-loader')
153
+ if (!loader) return
154
+ loader.setAttribute('data-loader-percent', '')
155
+ loader.style.setProperty('--studio-loader-percent', String(clampPercent(value)))
156
+ }
157
+
158
+ // A submitter that carries a pending loader.
159
+ export function pendingSubmitter(event) {
160
+ var submitter = event && (event.submitter || (event.detail && event.detail.formSubmission && event.detail.formSubmission.submitter))
161
+ if (!submitter || !submitter.querySelector) return null
162
+ return submitter.querySelector('.studio-loader[data-loader-pending]') ? submitter : null
163
+ }
164
+
165
+ var installed = false
166
+ export function installStudioLoader(doc) {
167
+ doc = doc || document
168
+ if (installed) return
169
+ installed = true
170
+
171
+ doc.addEventListener('turbo:visit', function (event) { if (userVisit(event)) showSoon('', doc) })
172
+ // turbo:reload: the response was not HTML (a download), or the visit failed,
173
+ // and Turbo hands the URL to the browser; no page may ever replace this one.
174
+ ;['turbo:load', 'turbo:render', 'turbo:before-cache', 'turbo:fetch-request-error', 'turbo:frame-load', 'turbo:reload'].forEach(function (name) {
175
+ doc.addEventListener(name, function () { hide(doc) })
176
+ })
177
+ // Leaving clears the page before the bfcache keeps it; a restore clears it
178
+ // again, in case it was kept some other way.
179
+ if (doc.defaultView) {
180
+ doc.defaultView.addEventListener('pagehide', function () { settle(doc) })
181
+ doc.defaultView.addEventListener('pageshow', function (event) { if (event.persisted) settle(doc) })
182
+ }
183
+
184
+ doc.addEventListener('turbo:submit-start', function (event) {
185
+ var form = event.target
186
+ if (form && form.hasAttribute && form.hasAttribute('data-studio-loader-overlay')) {
187
+ showSoon(form.getAttribute('data-studio-loader-overlay') || '', doc)
188
+ }
189
+ })
190
+ doc.addEventListener('turbo:submit-end', function (event) {
191
+ var form = event.target
192
+ if (form && form.hasAttribute && form.hasAttribute('data-studio-loader-overlay')) hide(doc)
193
+ })
194
+
195
+ // A form Turbo does not drive navigates away on its own; mark the button and,
196
+ // when asked, the overlay, until the next page replaces this one.
197
+ doc.addEventListener('submit', function (event) {
198
+ var form = event.target
199
+ if (event.defaultPrevented || !form || !form.getAttribute) return
200
+ var win = doc.defaultView
201
+ if (win && win.Turbo && form.getAttribute('data-turbo') !== 'false') return
202
+ var button = pendingSubmitter(event)
203
+ if (button) markBusy(button, doc)
204
+ if (form.hasAttribute('data-studio-loader-overlay')) { showSoon(form.getAttribute('data-studio-loader-overlay') || '', doc); armCap(doc) }
205
+ })
206
+
207
+ if (doc.defaultView && !doc.defaultView.StudioLoader) {
208
+ doc.defaultView.StudioLoader = {
209
+ show: function (caption) { return show(caption, doc) },
210
+ hide: function () { return hide(doc) },
211
+ setPercent: setPercent
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # "Request Google setup", sent to the team when someone presses it in the
5
+ # Google modal of an app whose OAuth client is not configured yet
6
+ # (Studio::GoogleSetupRequestsController, Studio::GoogleSignIn). Namespaced so
7
+ # a host's own mailers cannot shadow it; plain strings in, so an outbox row
8
+ # stores nothing but text.
9
+ class GoogleSetupMailer < ApplicationMailer
10
+ layout false
11
+
12
+ # The shared production OAuth client every app's callback is added to, and
13
+ # who adds it. Facts for the reader of the email, not configuration.
14
+ OAUTH_PROJECT = "enduring-maxim-491202-t6"
15
+
16
+ # host and redirect_uri come from the app's CONFIGURED canonical host
17
+ # (Studio::GoogleSignIn.canonical_host), never from the request, and are
18
+ # blank when the app names none: the email then says to set one.
19
+ def setup_requested(app_name, host, redirect_uri, to)
20
+ @app_name = app_name
21
+ @host = host.to_s.strip.presence
22
+ @redirect_uri = redirect_uri.to_s.strip.presence
23
+ @oauth_project = OAUTH_PROJECT
24
+ subject = "Google sign-in setup requested: #{app_name}"
25
+ subject += " (#{@host})" if @host
26
+ mail(to: to, subject: subject)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Studio
6
+ # The engine's default Google find-or-create (docs/USER_CONTRACT.md, "Google
7
+ # sign-in"), so an app signs people in with Google before it writes a line of
8
+ # its own User code.
9
+ #
10
+ # OmniauthCallbacksController calls Studio::OmniauthUser.resolve(User, ...),
11
+ # which uses the host's own User.from_omniauth when the class answers it and
12
+ # this module's find_or_create otherwise. A host that wants the default under
13
+ # its own name includes the concern; a host that defines `def
14
+ # self.from_omniauth` in the class body overrides it either way, because a
15
+ # method on the class itself is found before one from an extended module.
16
+ #
17
+ # The default, in order:
18
+ #
19
+ # 1. the account already holding this Google id (provider + uid);
20
+ # 2. else the account holding the address, compared case-insensitively, but
21
+ # only when Google verified the address: an unverified match answers
22
+ # :email_not_verified, since it proves nothing about who holds the
23
+ # mailbox;
24
+ # 3. else a new account, address downcased.
25
+ #
26
+ # THE SLUG COLLISION (Carl, 2026-10-29, on voicebox). The documented example
27
+ # created a second "Mia" whose name_slug was "mia" again; the unique slug index
28
+ # raised RecordNotUnique, the rescue looked for the address and the uid, found
29
+ # nobody, and sign-in failed. So the create checks the slug first, and a slug
30
+ # that is blank or already held becomes user-<id> (settled by Sluggable after
31
+ # the insert). A collision that survives that, with nobody to find, is logged
32
+ # with ErrorLog.capture! and answers nil, which the controller turns into a
33
+ # "use another method" redirect rather than a 500.
34
+ module OmniauthUser
35
+ extend ActiveSupport::Concern
36
+
37
+ class_methods do
38
+ def from_omniauth(auth, email_verified: false)
39
+ Studio::OmniauthUser.find_or_create(self, auth, email_verified: email_verified)
40
+ end
41
+ end
42
+
43
+ class << self
44
+ def resolve(model, auth, email_verified: false)
45
+ if model.respond_to?(:from_omniauth)
46
+ model.from_omniauth(auth, email_verified: email_verified)
47
+ else
48
+ find_or_create(model, auth, email_verified: email_verified)
49
+ end
50
+ end
51
+
52
+ def find_or_create(model, auth, email_verified: false)
53
+ provider = read(auth, "provider")
54
+ uid = read(auth, "uid")
55
+ return nil if provider.nil? || uid.nil?
56
+
57
+ user = model.find_by(provider: provider, uid: uid)
58
+ return user if user
59
+
60
+ email = normalize_email(read(auth, "info", "email"))
61
+ if email && (existing = find_by_email(model, email))
62
+ return :email_not_verified unless email_verified
63
+
64
+ existing.update!(provider: provider, uid: uid)
65
+ return existing
66
+ end
67
+
68
+ attributes = { provider: provider, uid: uid, email: email, name: read(auth, "info", "name") }
69
+ create_with_free_slug(model, attributes, email_verified: email_verified)
70
+ end
71
+
72
+ def normalize_email(value)
73
+ email = value.to_s.strip.downcase
74
+ email.empty? ? nil : email
75
+ end
76
+
77
+ def find_by_email(model, email)
78
+ return nil unless column?(model, "email")
79
+
80
+ model.where(model.arel_table[:email].lower.eq(email)).first
81
+ end
82
+
83
+ private
84
+
85
+ def create_with_free_slug(model, attributes, email_verified: false)
86
+ assignable = attributes.select { |key, value| !value.nil? && column?(model, key.to_s) }
87
+ last_error = nil
88
+
89
+ [false, true].each do |force_fallback|
90
+ record = model.new(assignable)
91
+ install_fallback_slug(record) if force_fallback || slug_needs_fallback?(model, record)
92
+ begin
93
+ model.transaction(requires_new: true) { record.save! }
94
+ return record
95
+ rescue ActiveRecord::RecordNotUnique => e
96
+ last_error = e
97
+ # A concurrent sign-in made the row first: that row is the answer.
98
+ # The Google id always identifies it; the address only when Google
99
+ # verified it, the same rule as step 2 above. An unverified address
100
+ # that collides with someone else's row hands that row to nobody.
101
+ found = model.find_by(provider: assignable[:provider], uid: assignable[:uid])
102
+ found ||= find_by_email(model, assignable[:email]) if email_verified && assignable[:email]
103
+ return found if found
104
+ end
105
+ end
106
+
107
+ log_unmatched_collision(last_error)
108
+ nil
109
+ end
110
+
111
+ def slug_needs_fallback?(model, record)
112
+ return false unless column?(model, "slug") && record.respond_to?(:name_slug, true)
113
+
114
+ candidate = begin
115
+ record.send(:name_slug).to_s
116
+ rescue StandardError
117
+ ""
118
+ end
119
+ candidate.strip.empty? || model.unscoped.where(slug: candidate).exists?
120
+ end
121
+
122
+ # user-<token> before the insert, user-<id> once the id exists: Sluggable
123
+ # writes the first in before_save and settles the second after_create.
124
+ def install_fallback_slug(record)
125
+ token = SecureRandom.hex(4)
126
+ record.define_singleton_method(:name_slug) { id ? "user-#{id}" : "user-#{token}" }
127
+ end
128
+
129
+ def log_unmatched_collision(error)
130
+ return unless error
131
+
132
+ Rails.logger.warn("[omniauth] Google sign-in collided with nobody to find: #{error.class}: #{error.message.to_s[0, 300]}")
133
+ ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
134
+ rescue StandardError => e
135
+ Rails.logger.warn("[omniauth] could not log the collision: #{e.class}")
136
+ end
137
+
138
+ def column?(model, name)
139
+ model.respond_to?(:attribute_names) && model.attribute_names.include?(name)
140
+ end
141
+
142
+ def read(auth, *path)
143
+ value = path.reduce(auth) do |node, key|
144
+ break nil if node.nil?
145
+
146
+ node.respond_to?(:[]) ? (node[key] || (node[key.to_sym] if node.is_a?(Hash))) : nil
147
+ end
148
+ string = value.to_s.strip
149
+ string.empty? ? nil : string
150
+ end
151
+ end
152
+ end
153
+ end
@@ -87,11 +87,9 @@
87
87
  </div>
88
88
  <% end %>
89
89
 
90
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
91
- class: "btn btn-google btn-lg w-full gap-3" do %>
92
- <%= render "components/google_logo" %>
93
- Sign up with Google
94
- <% end %>
90
+ <%# A POST to the strategy once the app has its OAuth client, the setup
91
+ modal until then (studio/google_sign_in/_button). %>
92
+ <%= render "studio/google_sign_in/button", label: "Sign up with Google" %>
95
93
  <% end %>
96
94
 
97
95
  <p class="text-center text-secondary text-sm mt-6">
@@ -87,11 +87,9 @@
87
87
  </div>
88
88
  <% end %>
89
89
 
90
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
91
- class: "btn btn-google btn-lg w-full gap-3" do %>
92
- <%= render "components/google_logo" %>
93
- Sign in with Google
94
- <% end %>
90
+ <%# A POST to the strategy once the app has its OAuth client, the setup
91
+ modal until then (studio/google_sign_in/_button). %>
92
+ <%= render "studio/google_sign_in/button", label: "Sign in with Google" %>
95
93
  <% end %>
96
94
 
97
95
  <p class="text-center text-secondary text-sm mt-6">
@@ -0,0 +1,18 @@
1
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; line-height: 1.5; color: #1f2937; max-width: 560px;">
2
+ <p>Someone asked for Google sign-in on <strong><%= @app_name %></strong><%= " (#{@host})" if @host %>.</p>
3
+ <p>The app already draws the "Sign in with Google" button; it opens a setup notice until the OAuth client is in place. Two steps finish it:</p>
4
+ <ol>
5
+ <li>
6
+ Add this redirect URI to the shared production OAuth client
7
+ (Google Cloud project <code><%= @oauth_project %></code>, admin@ Chrome profile; Steffon's lane):
8
+ <% if @redirect_uri %>
9
+ <p><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;"><%= @redirect_uri %></code></p>
10
+ <% else %>
11
+ <p>None yet: the app names no canonical host. Set <code>APP_HOST</code>, or <code>Studio.google_canonical_host</code>, to its production hostname; the URI is then <code>https://&lt;that host&gt;/auth/google_oauth2/callback</code>.</p>
12
+ <% end %>
13
+ </li>
14
+ <li>Set <code>GOOGLE_CLIENT_ID</code> and <code>GOOGLE_CLIENT_SECRET</code> on the app, then restart it.</li>
15
+ </ol>
16
+ <p>Once both are set, the button posts straight to Google. This request is sent at most once per app per day. The URI above comes from the app's configuration, never from the request that asked.</p>
17
+ <p style="color: #6b7280; font-size: 13px;">Guide: studio-engine docs/GOOGLE_AUTH_SETUP.md</p>
18
+ </div>
@@ -0,0 +1,23 @@
1
+ Someone asked for Google sign-in on <%= @app_name %><%= " (#{@host})" if @host %>.
2
+
3
+ The app already draws the "Sign in with Google" button; it opens a setup notice
4
+ until the OAuth client is in place. Two steps finish it:
5
+
6
+ 1. Add this redirect URI to the shared production OAuth client
7
+ (Google Cloud project <%= @oauth_project %>, admin@ Chrome profile; Steffon's lane):
8
+
9
+ <% if @redirect_uri -%>
10
+ <%= @redirect_uri %>
11
+ <% else -%>
12
+ (none yet: the app names no canonical host. Set APP_HOST, or
13
+ Studio.google_canonical_host, to its production hostname; the URI is then
14
+ https://<that host>/auth/google_oauth2/callback)
15
+ <% end -%>
16
+
17
+ 2. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET on the app, then restart it.
18
+
19
+ Once both are set, the button posts straight to Google. This request is sent at
20
+ most once per app per day. The URI above comes from the app's configuration,
21
+ never from the request that asked.
22
+
23
+ Guide: studio-engine docs/GOOGLE_AUTH_SETUP.md
@@ -0,0 +1,52 @@
1
+ <%# The "Sign in with Google" button, for every engine page that offers Google
2
+ (sessions/new, registrations/new, studio/profiles/_google_section).
3
+
4
+ Locals:
5
+ label — REQUIRED. The button's words ("Sign in with Google").
6
+ button_class — optional. Default "btn btn-google btn-lg w-full gap-3".
7
+ email_fallback — optional, default true. The modal's "Use email instead",
8
+ which closes it and focuses the page's email field. The
9
+ profile page, where the person is already signed in,
10
+ passes false and gets "Not now".
11
+
12
+ TWO STATES, by Studio::GoogleSignIn.button_posts?:
13
+
14
+ READY — the app has its OAuth client (or registered its own provider, or
15
+ OmniAuth is in test mode). A POST form to /auth/google_oauth2, Turbo off:
16
+ OmniAuth's middleware owns that path, and the request phase is POST-only
17
+ and CSRF-checked.
18
+
19
+ NOT YET — no client yet. The same button, opening a modal on a page-scoped
20
+ host (store googleSetupModals) that says Google sign-in is still being set
21
+ up, offers "Request Google setup" (POST /_studio/google_setup_request,
22
+ which mails the team once per app per day) and a way back to email.
23
+
24
+ The x-data on the wrapper is load-bearing: studio/modals/_scoped_host
25
+ declares no Alpine scope of its own, and without one above it the modal
26
+ registers, opens, and renders nothing (see studio/profiles/show). One button
27
+ per page; two would mount two hosts on one store. %>
28
+ <%
29
+ google_label = local_assigns.fetch(:label)
30
+ google_button_class = local_assigns.fetch(:button_class, "btn btn-google btn-lg w-full gap-3")
31
+ google_email_fallback = local_assigns.fetch(:email_fallback, true)
32
+ %>
33
+ <% if Studio::GoogleSignIn.button_posts? %>
34
+ <%= button_to Studio::GoogleSignIn::REQUEST_PATH, method: :post, data: { turbo: false, google_sign_in: "ready" },
35
+ class: google_button_class do %>
36
+ <%= render "components/google_logo" %>
37
+ <%= google_label %>
38
+ <% end %>
39
+ <% else %>
40
+ <div x-data data-google-sign-in="setup">
41
+ <button type="button" class="<%= google_button_class %>" aria-haspopup="dialog"
42
+ @click="$store.googleSetupModals.open('google-setup')">
43
+ <%= render "components/google_logo" %>
44
+ <%= google_label %>
45
+ </button>
46
+ <%= render "studio/modals/scoped_host", store: "googleSetupModals" do %>
47
+ <template x-if="$store.googleSetupModals.current()?.id === 'google-setup'">
48
+ <%= render "studio/google_sign_in/setup_modal", email_fallback: google_email_fallback %>
49
+ </template>
50
+ <% end %>
51
+ </div>
52
+ <% end %>
@@ -0,0 +1,55 @@
1
+ <%# The Google setup modal, opened by studio/google_sign_in/_button while the
2
+ app has no OAuth client. One root element: the host's template x-if clones
3
+ only the first. No double quote inside any x-data or @click below.
4
+
5
+ Locals:
6
+ email_fallback — true: "Use email instead" closes the modal and focuses
7
+ the page's email field. false: "Not now" only closes it.
8
+ The focus waits out the close: the host hands focus
9
+ back to the opener when its last card leaves (220ms,
10
+ CLOSE_ANIM_MS), which would otherwise undo it.
11
+
12
+ "Request Google setup" is a plain form POST with Turbo off, so it works with
13
+ the same CSRF token as every other engine form, and the flash on the page it
14
+ lands back on says what happened. Hidden when the host draws its own routes
15
+ without Studio.routes, which leaves nowhere to send it. %>
16
+ <%
17
+ setup_email_fallback = local_assigns.fetch(:email_fallback, true)
18
+ setup_request_path = respond_to?(:studio_google_setup_request_path) ? studio_google_setup_request_path : nil
19
+ %>
20
+ <div class="text-center" data-google-setup-modal>
21
+ <div class="flex justify-end -mt-2 -mr-2">
22
+ <button type="button" @click="$store.googleSetupModals.close()"
23
+ class="text-secondary hover:text-heading text-xl leading-none"
24
+ aria-label="Close">&times;</button>
25
+ </div>
26
+
27
+ <div class="mx-auto mb-4 w-12 h-12 rounded-full bg-white border border-subtle shadow-sm flex items-center justify-center">
28
+ <%= render "components/google_logo" %>
29
+ </div>
30
+
31
+ <h3 class="text-heading font-bold text-lg leading-tight">Google sign-in is almost ready</h3>
32
+ <p class="text-secondary text-sm mt-2">
33
+ Google sign-in is still being set up for <%= Studio.app_name %>.
34
+ <% if setup_request_path %>Ask the team to finish it, and<% else %>Until it is,<% end %>
35
+ <% if setup_email_fallback %>sign in with your email in the meantime.<% else %>use another sign-in method in the meantime.<% end %>
36
+ </p>
37
+
38
+ <div class="mt-6 flex flex-col gap-3">
39
+ <% if setup_request_path %>
40
+ <%= button_to "Request Google setup", setup_request_path, method: :post,
41
+ data: { turbo: false }, class: "btn btn-primary w-full", form: { class: "w-full" } %>
42
+ <% end %>
43
+
44
+ <% if setup_email_fallback %>
45
+ <button type="button" class="btn btn-neutral w-full"
46
+ @click="$store.googleSetupModals.close(); setTimeout(() => document.querySelector('input[type=email]')?.focus(), 260)">
47
+ Use email instead
48
+ </button>
49
+ <% else %>
50
+ <button type="button" class="btn btn-neutral w-full" @click="$store.googleSetupModals.close()">
51
+ Not now
52
+ </button>
53
+ <% end %>
54
+ </div>
55
+ </div>
@@ -0,0 +1,75 @@
1
+ <%# locals: (style:, size:, icon:, art:, label:, percent:, mono:, pending:, uid:, html: {}) %>
2
+ <%#
3
+ The branded loader (Studio::LoaderHelper#studio_loader, docs/LOADER.md): ONE
4
+ class, .studio-loader, sized by --studio-loader-size, styled per
5
+ data-loader-style by engine-motion.css (section "Branded loader"). The icon is
6
+ the app's own file; a signature style inlines the art's marked layers over it.
7
+
8
+ A pending loader sits inside a button: hidden from assistive tech (the button
9
+ carries aria-busy) and from sight until the button or its form is busy.
10
+ %>
11
+ <%
12
+ extra = html.dup
13
+ data = (extra.delete(:data) || {}).merge(loader_style: style.to_s.tr("_", "-"))
14
+ data[:loader_pending] = "" if pending
15
+ data[:loader_mono] = "" if mono
16
+ data[:loader_percent] = "" unless percent.nil?
17
+ vars = [ "--studio-loader-size: #{size}" ]
18
+ vars << "--studio-loader-percent: #{percent}" unless percent.nil?
19
+ vars << extra.delete(:style) if extra[:style]
20
+ classes = [ "studio-loader", extra.delete(:class) ].compact.join(" ")
21
+ draw_icon = lambda do |modifier = nil|
22
+ css = [ "studio-loader-icon", modifier ].compact.join(" ")
23
+ if icon.blank?
24
+ tag.span(class: css, data: { loader_blank: "" })
25
+ elsif mono
26
+ tag.span(class: css, style: "--studio-loader-mask: url(\"#{icon.to_s.gsub('"', '%22')}\")")
27
+ else
28
+ tag.img(src: icon, alt: "", class: css, draggable: "false", decoding: "async")
29
+ end
30
+ end
31
+ aria = pending ? { hidden: "true" } : { live: "polite" }
32
+ %>
33
+ <%= tag.span(class: classes, role: (pending ? nil : "status"), aria: aria, data: data, style: vars.join("; "), **extra) do %>
34
+ <% case style %>
35
+ <% when :breathe %>
36
+ <span class="studio-loader-halo" aria-hidden="true"></span>
37
+ <%= draw_icon.call %>
38
+ <% when :liquid %>
39
+ <%= draw_icon.call("studio-loader-ghost") %>
40
+ <span class="studio-loader-fill" aria-hidden="true"><%= draw_icon.call %></span>
41
+ <% when :orbit %>
42
+ <span class="studio-loader-track" aria-hidden="true"></span>
43
+ <%= draw_icon.call %>
44
+ <span class="studio-loader-orb" aria-hidden="true"><span class="studio-loader-trail"></span><span class="studio-loader-spark"></span></span>
45
+ <% when :steel %>
46
+ <svg class="studio-loader-frame" viewBox="0 0 100 100" aria-hidden="true"><path d="M0,14V0H14M86,0H100V14M100,86V100H86M14,100H0V86"/></svg>
47
+ <%= draw_icon.call %>
48
+ <% when :weld_bead %>
49
+ <%= draw_icon.call %>
50
+ <svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
51
+ <defs><filter id="<%= uid %>-glow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="<%= (art[:width] / 52.86).round(2) %>" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
52
+ <% outline = art[:layers].first %>
53
+ <% layers = studio_loader_bead_layers(art) %>
54
+ <% [ layers.reject { |l| l[:glow] }, layers.select { |l| l[:glow] } ].each_with_index do |group, glowing| %>
55
+ <%= tag.g(filter: (glowing == 1 ? "url(##{uid}-glow)" : nil), transform: outline[:transform]) do %>
56
+ <% group.each do |layer| %>
57
+ <%= tag.path(d: outline[:d], pathLength: "100", class: "studio-loader-bead", data: { layer: layer[:n] },
58
+ "stroke-width": layer[:width], "stroke-opacity": layer[:opacity],
59
+ "stroke-dasharray": "#{layer[:dash]} #{(100 - layer[:dash]).round(2)}",
60
+ style: "--studio-loader-bead-length: #{layer[:dash]}") %>
61
+ <% end %>
62
+ <% end %>
63
+ <% end %>
64
+ </svg>
65
+ <% when :sparkle %>
66
+ <%= draw_icon.call %>
67
+ <svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
68
+ <% art[:layers].each_with_index do |spark, index| %>
69
+ <%= tag.path(d: spark[:d], transform: spark[:transform], class: "studio-loader-sparkle",
70
+ style: "--studio-loader-i: #{index}; --studio-loader-sparks: #{art[:layers].size}") %>
71
+ <% end %>
72
+ </svg>
73
+ <% end %>
74
+ <% unless pending || label.blank? %><span class="sr-only"><%= label %></span><% end %>
75
+ <% end %>