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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48ec929ff6a1178b1f5d031f591f3e6f90f4e3257feb303063e40dcd9b142eeb
4
- data.tar.gz: c4215b12c48ac7dbcb829e53faca80412007f2da8c0d785641412d375f847f74
3
+ metadata.gz: 10f7eaf25386391e9c922bd4f6937c17bfba9edfd56c2d66fb0b7efbc47f0b0e
4
+ data.tar.gz: 6db5f0a4e76c6f40e7c24cc8411774c835c8a4a3bbd7187df19579b6d5d4b459
5
5
  SHA512:
6
- metadata.gz: 898cba360f134fbc33f800b329e0914a82ed6ecb2af4306dc907361a246845d7974f70f80b2f3740bace96bd2ea7d08ff93b3a4523e27e3a30c223445e442c95
7
- data.tar.gz: df32d0a535175c62b3390e8cbbbbc1df96d1be5ac4479711ee388cdbb3643914ec37ca3b04a43c79d1a4d12279145baa2777143079a633f7ba844bb141d9a99c
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
- user = User.from_omniauth(auth, email_verified: result.email_verified)
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)