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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +2 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +323 -0
- data/app/controllers/omniauth_callbacks_controller.rb +15 -2
- data/app/controllers/studio/google_setup_requests_controller.rb +66 -0
- data/app/helpers/studio/loader_helper.rb +79 -0
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/loader.js +214 -0
- data/app/mailers/studio/google_setup_mailer.rb +29 -0
- data/app/models/concerns/studio/omniauth_user.rb +153 -0
- data/app/views/registrations/new.html.erb +3 -5
- data/app/views/sessions/new.html.erb +3 -5
- data/app/views/studio/google_setup_mailer/setup_requested.html.erb +18 -0
- data/app/views/studio/google_setup_mailer/setup_requested.text.erb +23 -0
- data/app/views/studio/google_sign_in/_button.html.erb +52 -0
- data/app/views/studio/google_sign_in/_setup_modal.html.erb +55 -0
- data/app/views/studio/loader/_loader.html.erb +75 -0
- data/app/views/studio/loader/_overlay.html.erb +16 -0
- data/app/views/studio/profiles/_google_section.html.erb +5 -5
- data/app/views/style/_tricks.html.erb +59 -0
- data/lib/studio/engine.rb +25 -0
- data/lib/studio/google_sign_in.rb +361 -0
- data/lib/studio/loader.rb +158 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +53 -0
- data/studio-engine.gemspec +8 -0
- metadata +74 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10f7eaf25386391e9c922bd4f6937c17bfba9edfd56c2d66fb0b7efbc47f0b0e
|
|
4
|
+
data.tar.gz: 6db5f0a4e76c6f40e7c24cc8411774c835c8a4a3bbd7187df19579b6d5d4b459
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c881adcf7be3fd52979932145acbd8c4b51d213950a48d95f302bbebad10f7bbf2cde647e50bb6cca267b4f3b7ca81613969aa2e86d606e28ca9bdc692f1af7c
|
|
7
|
+
data.tar.gz: d0e05f2556bb259a9e05b704da3374e638c70fdba0cf6d634948922ad5a72203488239d62e07ce65ac1e0d3b73cc9cf590df3936c8f64086feaf85f57ed09530
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.119.0 — 2026-10-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The branded loader: every app's own icon, in its own colours, as its
|
|
12
|
+
loader.** `studio_loader` (inline, any size), `studio_loader_pending` (inside a
|
|
13
|
+
submit button, shown while it or its form is `aria-busy`) and
|
|
14
|
+
`studio_loader_overlay` (the page-load overlay, shown when a visit the user
|
|
15
|
+
started, a Turbo `advance` or `restore`, outlasts 350ms, never for a
|
|
16
|
+
background `replace` refresh; `window.StudioLoader.show/hide` for other long
|
|
17
|
+
waits). The overlay and busy buttons always clear: on `turbo:reload` (a
|
|
18
|
+
download at a URL with no extension), on `pagehide` and a bfcache `pageshow`
|
|
19
|
+
(Back after a `data-turbo="false"` submit), and at a `max_wait:` cap
|
|
20
|
+
(15000ms) for a form whose response was a download. The overlay's "Loading"
|
|
21
|
+
status is announced; it never marks itself `aria-busy`. One
|
|
22
|
+
class, `.studio-loader`, one size variable, `--studio-loader-size`. Six styles
|
|
23
|
+
from the Loader Lab: generic `breathe` (the default), `liquid` (takes a
|
|
24
|
+
percent), `orbit` and `steel`; signature `weld_bead` and `sparkle`, which need
|
|
25
|
+
an art file of marked layers. Declared with `config.loader`. Reduced motion
|
|
26
|
+
shows a still icon. Nothing renders until an app calls a helper. docs/LOADER.md.
|
|
27
|
+
|
|
28
|
+
- **Google sign-in ships ready with every app.** The engine now depends on
|
|
29
|
+
`omniauth`, `omniauth-google-oauth2` and `omniauth-rails_csrf_protection`,
|
|
30
|
+
registers the `google_oauth2` strategy itself (POST-only, `email,profile`,
|
|
31
|
+
`select_account`) and supplies a default `User.from_omniauth`
|
|
32
|
+
(`Studio::OmniauthUser`), so a new app needs no gem, initializer or User code
|
|
33
|
+
for Google. A host that registers its own provider keeps it (the engine reads
|
|
34
|
+
the built middleware stack and stands aside), and a host's own
|
|
35
|
+
`from_omniauth` wins. The default links a verified address
|
|
36
|
+
case-insensitively, refuses an unverified match (`:email_not_verified`), and
|
|
37
|
+
gives a same-name newcomer `user-<id>` instead of failing on the slug index.
|
|
38
|
+
`Studio.google_ready?` is true when `GOOGLE_CLIENT_ID` and
|
|
39
|
+
`GOOGLE_CLIENT_SECRET` are set; until then the Google button on `/login`,
|
|
40
|
+
`/signup` and `/profile` opens a modal whose "Request Google setup"
|
|
41
|
+
(`POST /_studio/google_setup_request`) mails `Studio.google_setup_request_to`
|
|
42
|
+
(team@mcritchie.studio) once per app per day with the redirect URI to
|
|
43
|
+
register, built from the app's configured canonical host
|
|
44
|
+
(`Studio.google_canonical_host`, else `APP_HOST`, else the first plain
|
|
45
|
+
`config.hosts` entry), never from the request; it answers 404 once the
|
|
46
|
+
button posts. Where the engine owns the strategy it pins OmniAuth's callback
|
|
47
|
+
host (`full_host` in production from an explicit host, else
|
|
48
|
+
`allowed_hosts` from `config.hosts`). The remaining manual step: add
|
|
49
|
+
`https://<host>/auth/google_oauth2/callback` to the shared OAuth client and
|
|
50
|
+
set the two env vars. docs/GOOGLE_AUTH_SETUP.md.
|
|
51
|
+
|
|
7
52
|
## 0.118.0 — 2026-10-28
|
|
8
53
|
|
|
9
54
|
### Added
|
data/README.md
CHANGED
|
@@ -30,7 +30,7 @@ resolved.
|
|
|
30
30
|
|
|
31
31
|
## What It Provides
|
|
32
32
|
|
|
33
|
-
- **Authentication**: Passwordless magic-link auth, optional password auth, Google OAuth via OmniAuth, Solana wallet sign-in, and optional one-way SSO patterns
|
|
33
|
+
- **Authentication**: Passwordless magic-link auth, optional password auth, Google OAuth via OmniAuth (ships ready: strategy, default `User.from_omniauth` and a setup modal until the OAuth client is set; docs/GOOGLE_AUTH_SETUP.md), Solana wallet sign-in, and optional one-way SSO patterns
|
|
34
34
|
- **Error handling**: `Studio::ErrorHandling` concern with `rescue_and_log`, `ErrorLog` model with `capture!`, error log viewer at `/error_logs`
|
|
35
35
|
- **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
|
|
36
36
|
- **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
|
|
@@ -40,6 +40,7 @@ resolved.
|
|
|
40
40
|
- **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
|
|
41
41
|
- **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
|
|
42
42
|
- **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
43
|
+
- **Branded loader**: `studio_loader`, `studio_loader_pending` and `studio_loader_overlay` draw the app's own icon in its theme colours for page loads, long waits and pending buttons, in the style `config.loader` names (Breathe by default). See [`docs/LOADER.md`](docs/LOADER.md).
|
|
43
44
|
- **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
|
|
44
45
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
45
46
|
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
.hold-stack/.fizz-bit the carbonation behind it (ported, TM)
|
|
29
29
|
.email-reject email field rejection shake + fade (extracted, TM)
|
|
30
30
|
|
|
31
|
+
Loader:
|
|
32
|
+
.studio-loader the app's icon as a branded loader (Loader Lab picks)
|
|
33
|
+
.studio-loader-overlay the page-load overlay around it (docs/LOADER.md)
|
|
34
|
+
|
|
31
35
|
Leveling:
|
|
32
36
|
.level-badge(-1..-10) progressive level pill ladder (ported, TM)
|
|
33
37
|
.level-up-pop level-up pop + glow burst (ported, TM)
|
|
@@ -2163,3 +2167,322 @@ body.modal-open { overflow: visible; }
|
|
|
2163
2167
|
30% { transform: translateY(-3px); opacity: 1; }
|
|
2164
2168
|
}
|
|
2165
2169
|
}
|
|
2170
|
+
|
|
2171
|
+
/* =============================================================================
|
|
2172
|
+
BRANDED LOADER — the app's own icon, in its own theme colours, as a loader.
|
|
2173
|
+
Ported from the Loader Lab (task branded-app-loader, Alex McRitchie's picks:
|
|
2174
|
+
01 Breathe, 03 Liquid fill, 04 Spark orbit, 05 Weld bead, 08 Sparkle,
|
|
2175
|
+
09 Steel square spin). Markup: app/views/studio/loader/_loader.html.erb,
|
|
2176
|
+
through Studio::LoaderHelper (studio_loader, studio_loader_pending,
|
|
2177
|
+
studio_loader_overlay). Rules: docs/LOADER.md.
|
|
2178
|
+
|
|
2179
|
+
ONE class, .studio-loader, and ONE size variable, --studio-loader-size; the
|
|
2180
|
+
style is data-loader-style. Generic styles (breathe, liquid, orbit, steel)
|
|
2181
|
+
draw the icon as an image and so fit any app; the signature styles
|
|
2182
|
+
(weld-bead, sparkle) animate layers the app's art file marks.
|
|
2183
|
+
|
|
2184
|
+
Tune: --studio-loader-size the box, any length (default 96px)
|
|
2185
|
+
--studio-loader-color halo, frame, sparkle tint (default primary)
|
|
2186
|
+
--studio-loader-hot the orbiting spark (default accent)
|
|
2187
|
+
--studio-loader-bead-1..6 the bead's cooling colours, coolest first
|
|
2188
|
+
|
|
2189
|
+
Under prefers-reduced-motion every loader is a STILL icon: no animation, and
|
|
2190
|
+
the moving parts (orbit spark, bead, sparkle flares, halo) are not drawn.
|
|
2191
|
+
========================================================================== */
|
|
2192
|
+
@property --studio-loader-level {
|
|
2193
|
+
syntax: "<number>";
|
|
2194
|
+
inherits: true;
|
|
2195
|
+
initial-value: 1;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
@property --studio-loader-wave {
|
|
2199
|
+
syntax: "<length-percentage>";
|
|
2200
|
+
inherits: true;
|
|
2201
|
+
initial-value: 0px;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
@layer components {
|
|
2205
|
+
.studio-loader {
|
|
2206
|
+
--studio-loader-size: 96px;
|
|
2207
|
+
--studio-loader-color: var(--color-primary);
|
|
2208
|
+
--studio-loader-hot: var(--color-accent);
|
|
2209
|
+
--studio-loader-bead-1: #5A1E0E;
|
|
2210
|
+
--studio-loader-bead-2: #A63A17;
|
|
2211
|
+
--studio-loader-bead-3: #D7602E;
|
|
2212
|
+
--studio-loader-bead-4: #F08A5A;
|
|
2213
|
+
--studio-loader-bead-5: #FFD2A6;
|
|
2214
|
+
--studio-loader-bead-6: #FFFFFF;
|
|
2215
|
+
position: relative;
|
|
2216
|
+
display: inline-grid;
|
|
2217
|
+
place-items: center;
|
|
2218
|
+
flex: none;
|
|
2219
|
+
width: var(--studio-loader-size);
|
|
2220
|
+
height: var(--studio-loader-size);
|
|
2221
|
+
vertical-align: middle;
|
|
2222
|
+
isolation: isolate;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
.studio-loader > * { grid-area: 1 / 1; }
|
|
2226
|
+
|
|
2227
|
+
.studio-loader-icon {
|
|
2228
|
+
display: block;
|
|
2229
|
+
width: 80%;
|
|
2230
|
+
height: 80%;
|
|
2231
|
+
object-fit: contain;
|
|
2232
|
+
user-select: none;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
.studio-loader-icon[data-loader-blank] {
|
|
2236
|
+
width: 44%;
|
|
2237
|
+
height: 44%;
|
|
2238
|
+
border-radius: 28%;
|
|
2239
|
+
background: var(--studio-loader-color);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/* Mono: the icon as a mask in the text colour, for a loader inside a filled
|
|
2243
|
+
button where the brand colour would vanish into the fill. */
|
|
2244
|
+
.studio-loader[data-loader-mono] { --studio-loader-color: currentColor; }
|
|
2245
|
+
.studio-loader[data-loader-mono] .studio-loader-icon:not(img):not([data-loader-blank]) {
|
|
2246
|
+
background: currentColor;
|
|
2247
|
+
-webkit-mask: var(--studio-loader-mask) center / contain no-repeat;
|
|
2248
|
+
mask: var(--studio-loader-mask) center / contain no-repeat;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/* ---- 01 Breathe: the icon swells and settles under a soft halo ---------- */
|
|
2252
|
+
.studio-loader[data-loader-style="breathe"] .studio-loader-icon {
|
|
2253
|
+
animation: studio-loader-breathe 2.6s ease-in-out infinite;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
.studio-loader-halo {
|
|
2257
|
+
position: absolute;
|
|
2258
|
+
inset: -8%;
|
|
2259
|
+
border-radius: 50%;
|
|
2260
|
+
background: radial-gradient(closest-side, color-mix(in srgb, var(--studio-loader-color) 42%, transparent), transparent);
|
|
2261
|
+
animation: studio-loader-halo 2.6s ease-in-out infinite;
|
|
2262
|
+
pointer-events: none;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/* ---- 03 Liquid fill: the icon fills under a drifting wave, then drains --- */
|
|
2266
|
+
.studio-loader[data-loader-style="liquid"] .studio-loader-icon { width: 84%; height: 84%; }
|
|
2267
|
+
|
|
2268
|
+
.studio-loader-ghost { opacity: 0.2; filter: grayscale(1) brightness(1.4); }
|
|
2269
|
+
|
|
2270
|
+
.studio-loader-fill {
|
|
2271
|
+
--studio-loader-wave-h: calc(var(--studio-loader-size) * 0.07);
|
|
2272
|
+
position: absolute;
|
|
2273
|
+
inset: 0;
|
|
2274
|
+
display: grid;
|
|
2275
|
+
place-items: center;
|
|
2276
|
+
-webkit-mask:
|
|
2277
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4Q10 0 20 4T40 4V8H0Z'/%3E%3C/svg%3E") repeat-x,
|
|
2278
|
+
linear-gradient(#000, #000) no-repeat;
|
|
2279
|
+
mask:
|
|
2280
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4Q10 0 20 4T40 4V8H0Z'/%3E%3C/svg%3E") repeat-x,
|
|
2281
|
+
linear-gradient(#000, #000) no-repeat;
|
|
2282
|
+
-webkit-mask-size: calc(var(--studio-loader-size) * 0.5) var(--studio-loader-wave-h), 100% 100%;
|
|
2283
|
+
mask-size: calc(var(--studio-loader-size) * 0.5) var(--studio-loader-wave-h), 100% 100%;
|
|
2284
|
+
-webkit-mask-position:
|
|
2285
|
+
var(--studio-loader-wave) calc(var(--studio-loader-level) * var(--studio-loader-size) - var(--studio-loader-wave-h) + 1px),
|
|
2286
|
+
0 calc(var(--studio-loader-level) * var(--studio-loader-size));
|
|
2287
|
+
mask-position:
|
|
2288
|
+
var(--studio-loader-wave) calc(var(--studio-loader-level) * var(--studio-loader-size) - var(--studio-loader-wave-h) + 1px),
|
|
2289
|
+
0 calc(var(--studio-loader-level) * var(--studio-loader-size));
|
|
2290
|
+
animation: studio-loader-level 4.4s ease-in-out infinite, studio-loader-wave 1.9s linear infinite;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/* With a percent the level follows it (studio_loader percent:, or
|
|
2294
|
+
StudioLoader.setPercent), and only the wave moves. */
|
|
2295
|
+
.studio-loader[data-loader-percent] .studio-loader-fill {
|
|
2296
|
+
--studio-loader-level: calc(1 - var(--studio-loader-percent, 0) / 100);
|
|
2297
|
+
animation: studio-loader-wave 1.9s linear infinite;
|
|
2298
|
+
transition: --studio-loader-level 0.45s cubic-bezier(.12, .82, .24, 1);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/* ---- 04 Spark orbit: a white-hot spark circles the icon ------------------ */
|
|
2302
|
+
.studio-loader[data-loader-style="orbit"] .studio-loader-icon { width: 62%; height: 62%; }
|
|
2303
|
+
|
|
2304
|
+
.studio-loader-track {
|
|
2305
|
+
position: absolute;
|
|
2306
|
+
inset: 0;
|
|
2307
|
+
border-radius: 50%;
|
|
2308
|
+
border: max(1px, calc(var(--studio-loader-size) * 0.012)) solid color-mix(in srgb, var(--studio-loader-hot) 16%, transparent);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
.studio-loader-orb {
|
|
2312
|
+
position: absolute;
|
|
2313
|
+
inset: 0;
|
|
2314
|
+
animation: studio-loader-spin 1.9s linear infinite;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
.studio-loader-trail {
|
|
2318
|
+
position: absolute;
|
|
2319
|
+
inset: 0;
|
|
2320
|
+
border-radius: 50%;
|
|
2321
|
+
background: conic-gradient(from 0deg, transparent 0turn 0.66turn, color-mix(in srgb, var(--studio-loader-hot) 70%, transparent) 0.96turn, color-mix(in srgb, var(--studio-loader-hot) 25%, #fff) 1turn);
|
|
2322
|
+
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - max(2px, var(--studio-loader-size) * 0.035)), #000 calc(100% - max(1.5px, var(--studio-loader-size) * 0.03)));
|
|
2323
|
+
mask: radial-gradient(farthest-side, transparent calc(100% - max(2px, var(--studio-loader-size) * 0.035)), #000 calc(100% - max(1.5px, var(--studio-loader-size) * 0.03)));
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
.studio-loader-spark {
|
|
2327
|
+
position: absolute;
|
|
2328
|
+
left: 50%;
|
|
2329
|
+
top: max(1px, calc(var(--studio-loader-size) * 0.016));
|
|
2330
|
+
width: max(4px, calc(var(--studio-loader-size) * 0.085));
|
|
2331
|
+
aspect-ratio: 1;
|
|
2332
|
+
border-radius: 50%;
|
|
2333
|
+
transform: translate(-50%, -50%);
|
|
2334
|
+
background: radial-gradient(circle, #fff 0 30%, color-mix(in srgb, var(--studio-loader-hot) 45%, #fff) 45%, var(--studio-loader-hot) 72%, transparent 74%);
|
|
2335
|
+
box-shadow: 0 0 calc(var(--studio-loader-size) * 0.09) calc(var(--studio-loader-size) * 0.02) color-mix(in srgb, var(--studio-loader-hot) 70%, transparent);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/* ---- 09 Steel square spin: quarter-turn snaps inside drafting corners ---- */
|
|
2339
|
+
.studio-loader[data-loader-style="steel"] .studio-loader-icon {
|
|
2340
|
+
width: 60%;
|
|
2341
|
+
height: 60%;
|
|
2342
|
+
animation: studio-loader-snap 3.6s infinite;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
.studio-loader-frame {
|
|
2346
|
+
position: absolute;
|
|
2347
|
+
inset: 0;
|
|
2348
|
+
width: 100%;
|
|
2349
|
+
height: 100%;
|
|
2350
|
+
overflow: visible;
|
|
2351
|
+
fill: none;
|
|
2352
|
+
stroke: var(--studio-loader-color);
|
|
2353
|
+
stroke-opacity: 0.45;
|
|
2354
|
+
stroke-width: 2.5;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/* ---- Signature styles: the art's marked layers over the icon ------------- */
|
|
2358
|
+
.studio-loader-art {
|
|
2359
|
+
display: block;
|
|
2360
|
+
width: 80%;
|
|
2361
|
+
height: 80%;
|
|
2362
|
+
overflow: visible;
|
|
2363
|
+
pointer-events: none;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/* 05 Weld bead: a hot point runs the outline and cools behind itself. */
|
|
2367
|
+
.studio-loader-bead {
|
|
2368
|
+
fill: none;
|
|
2369
|
+
stroke-linecap: round;
|
|
2370
|
+
stroke-linejoin: round;
|
|
2371
|
+
animation: studio-loader-bead 3.6s linear infinite;
|
|
2372
|
+
animation-delay: calc((var(--studio-loader-bead-length) / 100 - 1) * 3.6s);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
.studio-loader-bead[data-layer="1"] { stroke: var(--studio-loader-bead-1); }
|
|
2376
|
+
.studio-loader-bead[data-layer="2"] { stroke: var(--studio-loader-bead-2); }
|
|
2377
|
+
.studio-loader-bead[data-layer="3"] { stroke: var(--studio-loader-bead-3); }
|
|
2378
|
+
.studio-loader-bead[data-layer="4"] { stroke: var(--studio-loader-bead-4); }
|
|
2379
|
+
.studio-loader-bead[data-layer="5"] { stroke: var(--studio-loader-bead-5); }
|
|
2380
|
+
.studio-loader-bead[data-layer="6"] { stroke: var(--studio-loader-bead-6); }
|
|
2381
|
+
|
|
2382
|
+
/* 08 Sparkle: the icon's own sparkles flare in turn, in the art's order. */
|
|
2383
|
+
.studio-loader-sparkle {
|
|
2384
|
+
fill: color-mix(in srgb, var(--studio-loader-color) 15%, #fff);
|
|
2385
|
+
filter: drop-shadow(0 0 2px color-mix(in srgb, var(--studio-loader-color) 60%, #fff));
|
|
2386
|
+
transform-box: fill-box;
|
|
2387
|
+
transform-origin: center;
|
|
2388
|
+
opacity: 0;
|
|
2389
|
+
animation: studio-loader-twinkle calc(var(--studio-loader-sparks, 5) * 0.6s) infinite;
|
|
2390
|
+
animation-delay: calc(var(--studio-loader-i, 0) * 0.6s - var(--studio-loader-sparks, 5) * 0.6s);
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/* ---- Pending: inside a button, shown only while it or its form is busy --- */
|
|
2394
|
+
.studio-loader[data-loader-pending] { display: none; }
|
|
2395
|
+
:where(form, button)[aria-busy="true"] .studio-loader[data-loader-pending] { display: inline-grid; }
|
|
2396
|
+
:where(form, button)[aria-busy="true"] [data-loader-idle] { display: none; }
|
|
2397
|
+
.studio-loader[data-loader-pending] .studio-loader-halo { display: none; }
|
|
2398
|
+
.studio-loader[data-loader-pending] .studio-loader-icon { width: 100%; height: 100%; animation-duration: 1.4s; }
|
|
2399
|
+
|
|
2400
|
+
/* ---- The page-load overlay (studio_loader_overlay) ----------------------- */
|
|
2401
|
+
.studio-loader-overlay {
|
|
2402
|
+
position: fixed;
|
|
2403
|
+
inset: 0;
|
|
2404
|
+
z-index: var(--z-alert);
|
|
2405
|
+
display: grid;
|
|
2406
|
+
place-items: center;
|
|
2407
|
+
background: color-mix(in srgb, var(--color-page) 78%, transparent);
|
|
2408
|
+
-webkit-backdrop-filter: blur(3px);
|
|
2409
|
+
backdrop-filter: blur(3px);
|
|
2410
|
+
opacity: 0;
|
|
2411
|
+
transition: opacity 0.2s ease;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
.studio-loader-overlay[hidden] { display: none; }
|
|
2415
|
+
.studio-loader-overlay[data-state="shown"] { opacity: 1; }
|
|
2416
|
+
|
|
2417
|
+
.studio-loader-overlay-panel {
|
|
2418
|
+
display: grid;
|
|
2419
|
+
justify-items: center;
|
|
2420
|
+
gap: 0.75rem;
|
|
2421
|
+
padding: 1.5rem;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
.studio-loader-caption {
|
|
2425
|
+
margin: 0;
|
|
2426
|
+
font-size: 0.875rem;
|
|
2427
|
+
color: var(--color-text-secondary);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
.studio-loader-caption:empty { display: none; }
|
|
2431
|
+
|
|
2432
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2433
|
+
.studio-loader,
|
|
2434
|
+
.studio-loader *,
|
|
2435
|
+
.studio-loader-overlay {
|
|
2436
|
+
animation: none !important;
|
|
2437
|
+
transition: none !important;
|
|
2438
|
+
}
|
|
2439
|
+
.studio-loader-halo,
|
|
2440
|
+
.studio-loader-orb,
|
|
2441
|
+
.studio-loader-art { display: none; }
|
|
2442
|
+
.studio-loader-fill { --studio-loader-level: 0.45; }
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
@keyframes studio-loader-breathe {
|
|
2446
|
+
0%, 100% { transform: scale(0.9); opacity: 0.55; }
|
|
2447
|
+
50% { transform: scale(1); opacity: 1; }
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
@keyframes studio-loader-halo {
|
|
2451
|
+
0%, 100% { opacity: 0; transform: scale(0.85); }
|
|
2452
|
+
50% { opacity: 0.9; transform: scale(1.04); }
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
@keyframes studio-loader-level {
|
|
2456
|
+
0% { --studio-loader-level: 1.06; }
|
|
2457
|
+
55%, 64% { --studio-loader-level: -0.05; }
|
|
2458
|
+
100% { --studio-loader-level: 1.06; }
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
@keyframes studio-loader-wave {
|
|
2462
|
+
from { --studio-loader-wave: 0px; }
|
|
2463
|
+
to { --studio-loader-wave: calc(var(--studio-loader-size) * -0.5); }
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
@keyframes studio-loader-spin {
|
|
2467
|
+
to { transform: rotate(360deg); }
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
@keyframes studio-loader-snap {
|
|
2471
|
+
0% { transform: rotate(0); animation-timing-function: cubic-bezier(.76, 0, .12, 1); }
|
|
2472
|
+
12.5%, 25% { transform: rotate(90deg); animation-timing-function: cubic-bezier(.76, 0, .12, 1); }
|
|
2473
|
+
37.5%, 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(.76, 0, .12, 1); }
|
|
2474
|
+
62.5%, 75% { transform: rotate(270deg); animation-timing-function: cubic-bezier(.76, 0, .12, 1); }
|
|
2475
|
+
87.5%, 100% { transform: rotate(360deg); }
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
@keyframes studio-loader-bead {
|
|
2479
|
+
from { stroke-dashoffset: 0; }
|
|
2480
|
+
to { stroke-dashoffset: -100; }
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
@keyframes studio-loader-twinkle {
|
|
2484
|
+
0% { transform: scale(0.8); opacity: 0; }
|
|
2485
|
+
9% { transform: scale(1.7) rotate(25deg); opacity: 1; animation-timing-function: ease-in; }
|
|
2486
|
+
26%, 100% { transform: scale(0.8); opacity: 0; }
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
# email_verified + expiry before trusting the identity. Only a Google-confirmed
|
|
7
7
|
# verified email is allowed to find-or-create an account.
|
|
8
8
|
#
|
|
9
|
+
# The strategy itself is registered by the engine (Studio::GoogleSignIn) unless
|
|
10
|
+
# the host registered its own.
|
|
11
|
+
#
|
|
9
12
|
# Engine GENERIC base. turf-monster OVERRIDES this controller for popup-mode,
|
|
10
13
|
# account merge, wallet-collision stashing, and funnel attribution.
|
|
11
14
|
class OmniauthCallbacksController < ApplicationController
|
|
@@ -19,7 +22,10 @@ class OmniauthCallbacksController < ApplicationController
|
|
|
19
22
|
return redirect_to login_path, alert: "Google sign-in could not be verified. Please try again."
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
# The host's own User.from_omniauth when it defines one, else the engine's
|
|
26
|
+
# default (Studio::OmniauthUser), so a new app signs people in with Google
|
|
27
|
+
# before it writes any User code of its own.
|
|
28
|
+
user = Studio::OmniauthUser.resolve(User, auth, email_verified: result.email_verified)
|
|
23
29
|
unless user.is_a?(User)
|
|
24
30
|
return redirect_to login_path, alert: "Google sign-in couldn't be completed. Please try another method."
|
|
25
31
|
end
|
|
@@ -28,7 +34,14 @@ class OmniauthCallbacksController < ApplicationController
|
|
|
28
34
|
set_app_session(user)
|
|
29
35
|
redirect_to root_path, notice: "Signed in with Google!"
|
|
30
36
|
end
|
|
31
|
-
rescue StandardError
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
# Recorded before the redirect, so a failing sign-in shows up somewhere the
|
|
39
|
+
# team looks rather than only as a flash the person dismisses.
|
|
40
|
+
begin
|
|
41
|
+
ErrorLog.capture!(e) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
Rails.logger.warn("[omniauth] could not log the Google sign-in failure: #{e.class}")
|
|
44
|
+
end
|
|
32
45
|
redirect_to login_path, alert: "Google sign-in failed. Please try again."
|
|
33
46
|
end
|
|
34
47
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# POST /_studio/google_setup_request: "Request Google setup", from the modal
|
|
5
|
+
# the Google button opens while the app has no OAuth client
|
|
6
|
+
# (Studio::GoogleSignIn, studio/google_sign_in/_button).
|
|
7
|
+
#
|
|
8
|
+
# Unauthenticated, because the person asking cannot sign in yet, and CSRF-
|
|
9
|
+
# checked like any form post. Three rules keep a stranger from turning it
|
|
10
|
+
# into a mail cannon or a way to plant a callback (Carl and Steffon,
|
|
11
|
+
# 2026-10-29):
|
|
12
|
+
#
|
|
13
|
+
# * NOTHING IN IT COMES FROM THE REQUEST'S HOST. The daily claim is keyed on
|
|
14
|
+
# the app (Studio::GoogleSignIn.app_key), so a forged Host header, port or
|
|
15
|
+
# X-Forwarded-Host cannot mint a fresh day; and the redirect URI in the
|
|
16
|
+
# email is built from the app's configured canonical host
|
|
17
|
+
# (Studio::GoogleSignIn.canonical_redirect_uri), so ops is never asked to
|
|
18
|
+
# register a host an attacker chose.
|
|
19
|
+
# * ONCE PER APP PER DAY (Studio::GoogleSignIn.claim_daily_request).
|
|
20
|
+
# * ONLY WHILE THE BUTTON SHOWS THE MODAL. An app whose button already
|
|
21
|
+
# posts to Google (client configured, or its own provider) answers 404.
|
|
22
|
+
#
|
|
23
|
+
# Every accepted request writes a log line, the repeats included, so the logs
|
|
24
|
+
# show demand.
|
|
25
|
+
class GoogleSetupRequestsController < ::ApplicationController
|
|
26
|
+
skip_before_action :require_authentication, raise: false
|
|
27
|
+
|
|
28
|
+
def create
|
|
29
|
+
return head(:not_found) if Studio::GoogleSignIn.button_posts?
|
|
30
|
+
|
|
31
|
+
redirect_uri = Studio::GoogleSignIn.canonical_redirect_uri
|
|
32
|
+
notified = Studio::GoogleSignIn.claim_daily_request
|
|
33
|
+
|
|
34
|
+
Rails.logger.info(
|
|
35
|
+
"[google-setup] request app=#{Studio.app_name.inspect} " \
|
|
36
|
+
"redirect_uri=#{redirect_uri || 'unconfigured'} notified=#{notified}"
|
|
37
|
+
)
|
|
38
|
+
deliver(redirect_uri) if notified
|
|
39
|
+
|
|
40
|
+
notice = if notified
|
|
41
|
+
"Thanks. We've asked the team to finish setting up Google sign-in. Use email for now."
|
|
42
|
+
else
|
|
43
|
+
"The team already has a request to set up Google sign-in today. Use email for now."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
respond_to do |format|
|
|
47
|
+
format.json { render json: { requested: true, notified: notified, message: notice } }
|
|
48
|
+
format.any { redirect_to login_path, notice: notice }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def deliver(redirect_uri)
|
|
55
|
+
recipient = Studio.google_setup_request_to
|
|
56
|
+
Studio::Email.deliver(Studio::GoogleSetupMailer, :setup_requested,
|
|
57
|
+
Studio.app_name.to_s, Studio::GoogleSignIn.canonical_host.to_s,
|
|
58
|
+
redirect_uri.to_s, recipient, to: recipient)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
# The person asking still gets an answer; the failure goes where the team
|
|
61
|
+
# looks for failures.
|
|
62
|
+
Rails.logger.warn("[google-setup] the request email failed: #{e.class}: #{e.message.to_s[0, 200]}")
|
|
63
|
+
ErrorLog.capture!(e) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# The branded loader (docs/LOADER.md), as view helpers. Every one draws the
|
|
7
|
+
# app's own icon (Studio.loader_icon) in its theme colours, in the style the
|
|
8
|
+
# app declared with Studio.loader; there is no per-app markup.
|
|
9
|
+
#
|
|
10
|
+
# <%= studio_loader %> 96px, the app's style
|
|
11
|
+
# <%= studio_loader size: 20, label: "Saving" %> inline, beside text
|
|
12
|
+
# <%= studio_loader style: :liquid, percent: 40 %> a fill that follows a percent
|
|
13
|
+
# <%= studio_loader_pending %> inside a submit button
|
|
14
|
+
# <%= studio_loader_overlay %> the page-load overlay (layout, once)
|
|
15
|
+
#
|
|
16
|
+
# Prefixed names for the reason Studio::GeoHelper gives: every helper module is
|
|
17
|
+
# included into every view.
|
|
18
|
+
module LoaderHelper
|
|
19
|
+
# Bead layers, coolest first: [dash length of 100, stroke width in a 264-unit
|
|
20
|
+
# icon, opacity]. The colours are CSS (--studio-loader-bead-1..6), so a theme
|
|
21
|
+
# can retune them. The last three glow.
|
|
22
|
+
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
|
+
LOADER_BEAD_UNIT = 264.3
|
|
24
|
+
|
|
25
|
+
def studio_loader(size: 96, style: nil, icon: nil, art: nil, label: "Loading", percent: nil,
|
|
26
|
+
tone: nil, pending: false, **html)
|
|
27
|
+
config = Studio.loader
|
|
28
|
+
style = style ? Studio::Loader.style!(style) : config[:style]
|
|
29
|
+
art_data = nil
|
|
30
|
+
if Studio::Loader.art_style?(style)
|
|
31
|
+
art_data = Studio::Loader.art(art || config[:art], style)
|
|
32
|
+
style = Studio::Loader::DEFAULT_STYLE if art_data.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
render "studio/loader/loader",
|
|
36
|
+
style: style,
|
|
37
|
+
size: studio_loader_size(size),
|
|
38
|
+
icon: icon || Studio.loader_icon,
|
|
39
|
+
art: art_data,
|
|
40
|
+
label: label,
|
|
41
|
+
percent: percent.nil? ? nil : percent.to_f.clamp(0, 100).round(1),
|
|
42
|
+
mono: (tone || (pending ? :mono : nil)).to_s == "mono",
|
|
43
|
+
pending: pending,
|
|
44
|
+
uid: "studio-loader-#{SecureRandom.hex(4)}",
|
|
45
|
+
html: html
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The small loader a submit button carries. Hidden until the button, or the
|
|
49
|
+
# form it submits, is aria-busy (Turbo marks the form; studio/loader marks
|
|
50
|
+
# the button for a form Turbo does not drive). Drawn in the button's own
|
|
51
|
+
# text colour, so it reads on any fill.
|
|
52
|
+
def studio_loader_pending(size: 18, style: :breathe, **options)
|
|
53
|
+
studio_loader(size: size, style: style, pending: true, **options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The full-page overlay: once, in the layout's body. studio/loader shows it
|
|
57
|
+
# when a visit the user started (never a background replace) or a marked
|
|
58
|
+
# form outlasts `delay` ms, and window.StudioLoader.show() / hide() drive it
|
|
59
|
+
# for any other long wait. A submit Turbo does not drive clears its overlay
|
|
60
|
+
# and busy button after `max_wait` ms, so a response that became a download
|
|
61
|
+
# never leaves them up; a Turbo visit is never capped.
|
|
62
|
+
# Any studio_loader option (style:, icon:, art:) passes through.
|
|
63
|
+
def studio_loader_overlay(label: "Loading", delay: 350, max_wait: 15_000, size: 96, **options)
|
|
64
|
+
render "studio/loader/overlay", label: label, delay: delay.to_i, max_wait: max_wait.to_i, size: size, options: options
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def studio_loader_size(size)
|
|
68
|
+
size.is_a?(Numeric) ? "#{size}px" : size.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The stroke layers a weld bead draws over the art's outline path.
|
|
72
|
+
def studio_loader_bead_layers(art)
|
|
73
|
+
unit = art[:width] / LOADER_BEAD_UNIT
|
|
74
|
+
LOADER_BEAD_LAYERS.each_with_index.map do |(dash, width, opacity), index|
|
|
75
|
+
{ n: index + 1, dash: dash, width: (width * unit).round(2), opacity: opacity, glow: index >= 3 }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -17,9 +17,11 @@ import LinkSidebarController from "studio/controllers/link_sidebar_controller"
|
|
|
17
17
|
import IdentityMiniController from "studio/controllers/identity_mini_controller"
|
|
18
18
|
import { startPinnedStack } from "studio/pinned_stack"
|
|
19
19
|
import { installAlpineShims } from "studio/alpine_shims"
|
|
20
|
+
import { installStudioLoader } from "studio/loader"
|
|
20
21
|
|
|
21
22
|
installAlpineShims()
|
|
22
23
|
startPinnedStack()
|
|
24
|
+
installStudioLoader()
|
|
23
25
|
|
|
24
26
|
application.register("nav-collapse", NavCollapseController)
|
|
25
27
|
application.register("modal-host", ModalHostController)
|