studio-engine 0.117.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: 27c8703c859ad0039101776ceeeee457d6f2bd47df12a5180ebc20aa1127ad8c
4
- data.tar.gz: 6b05c69deb801b08c6d1f6b31012220bf07b43cebb06b22e8becbd14fab159b0
3
+ metadata.gz: 10f7eaf25386391e9c922bd4f6937c17bfba9edfd56c2d66fb0b7efbc47f0b0e
4
+ data.tar.gz: 6db5f0a4e76c6f40e7c24cc8411774c835c8a4a3bbd7187df19579b6d5d4b459
5
5
  SHA512:
6
- metadata.gz: 92bb22f3f1c1e28d7b2606541f2880ff098f3ff18986f29476fdfe66a9713c8e7565c72059e684a66b1c82251b86f0984b95bea4e8b2640775ddb0401a02e03a
7
- data.tar.gz: 81332763ef29a2ccb68ca9f143b3c828022b5a3f42e6d47b30c63334ed59363a6decabe7e611c9e471bc1b9e539ade2be5f0b8ce71373d54ccce7d86f607ef05
6
+ metadata.gz: c881adcf7be3fd52979932145acbd8c4b51d213950a48d95f302bbebad10f7bbf2cde647e50bb6cca267b4f3b7ca81613969aa2e86d606e28ca9bdc692f1af7c
7
+ data.tar.gz: d0e05f2556bb259a9e05b704da3374e638c70fdba0cf6d634948922ad5a72203488239d62e07ce65ac1e0d3b73cc9cf590df3936c8f64086feaf85f57ed09530
data/CHANGELOG.md CHANGED
@@ -4,6 +4,63 @@ 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
+
52
+ ## 0.118.0 — 2026-10-28
53
+
54
+ ### Added
55
+
56
+ - **The type scale: five text roles every app draws from.** `type-title` (6x),
57
+ `type-heading` (3x), `type-subhead` (1.5x), `type-body` (1x) and `type-fine`
58
+ (0.75x), each a multiple of `--type-base`, which the theme tag sets from
59
+ `Studio.type_base` (16). A `type-dense` container swaps in
60
+ `Studio.type_dense_base` (14) for admin and ops pages. Title and heading take
61
+ `Studio.type_display_weight` (800), the rest `Studio.type_text_weight` (400).
62
+ Nothing changes on a page until it uses a role. README "Type scale".
63
+
7
64
  ## 0.117.0 — 2026-10-28
8
65
 
9
66
  ### Changed
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).
@@ -310,6 +311,47 @@ flag — it cannot know where the reader is sitting, so only the reader's machin
310
311
  may assert one. A host that omits the script still gets working stamps, just
311
312
  frozen in the app's timezone. Specimen: `/admin/style` → Tricks → Time stamps.
312
313
 
314
+ ### Type scale — `type-*`
315
+
316
+ The one set of text sizes every Studio app uses (Alex McRitchie's standard,
317
+ 2026-10-28; the hub's `/logos/<brand>` Type Scale card shows it per brand).
318
+ Each role is a multiple of one base:
319
+
320
+ | Utility | Multiple | Public base (16 px) | Dense base (14 px) | Weight |
321
+ |---|---|---|---|---|
322
+ | `type-title` | 6x | 96 px | 84 px | display |
323
+ | `type-heading` | 3x | 48 px | 42 px | display |
324
+ | `type-subhead` | 1.5x | 24 px | 21 px | text |
325
+ | `type-body` | 1x | 16 px | 14 px | text |
326
+ | `type-fine` | 0.75x | 12 px | 10.5 px | text |
327
+
328
+ ```erb
329
+ <h1 class="type-title text-heading">Winter Run 2026</h1>
330
+ <p class="type-body text-body">Since 2016 ...</p>
331
+
332
+ <%# an admin or ops page: everything inside takes the dense base %>
333
+ <main class="type-dense">
334
+ <h1 class="type-heading text-heading">Releases</h1>
335
+ </main>
336
+ ```
337
+
338
+ - **Public pages** use the public base. **Admin and ops pages** put
339
+ `type-dense` on their container: the roles inside keep their ratios on the
340
+ dense base.
341
+ - **Set the base and weights from the brand kit**, in
342
+ `config/initializers/studio.rb`: `Studio.type_base`,
343
+ `Studio.type_dense_base`, `Studio.type_display_weight`,
344
+ `Studio.type_text_weight`. The theme tag (`studio_theme_css_tag`) writes them
345
+ as `--type-base`, `--type-dense-base`, `--type-display-weight` and
346
+ `--type-text-weight`; a bad value raises, naming the setting.
347
+ - **A role sets size, weight and line height only.** Colour stays with
348
+ `text-heading`, `text-body` and the rest, which are colour utilities, not
349
+ sizes.
350
+ - **Nothing is smaller than `type-fine`.** A new page uses the roles, not
351
+ `text-xs` or `text-2xs`; existing pages move over app by app.
352
+ - The multiples live in `Studio::TypeScale::ROLES` and in `engine.css`;
353
+ `test/lib/type_scale_contract_test.rb` keeps the two the same.
354
+
313
355
  ### Tints and shades — `.scrim`
314
356
 
315
357
  Text laid over media on a PAGE (a hero photo, a video, a banner) reads through
@@ -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
+ }
@@ -179,6 +179,50 @@ body.modal-open .studio-app-banner {
179
179
  @apply bg-inset rounded-xl border border-subtle p-4 overflow-x-auto;
180
180
  }
181
181
 
182
+ /* -- Type scale ------------------------------------------------------------
183
+ The one set of text sizes every app draws from (Studio::TypeScale, README
184
+ "Type scale"). Each role is a multiple of --type-base: title 6x, heading 3x,
185
+ subhead 1.5x, body 1x, fine print 0.75x. The theme tag sets the app's base
186
+ (Studio.type_base, 16px) and weights; `type-dense` swaps in the dense base
187
+ (Studio.type_dense_base, 14px) for everything inside it, for admin and ops
188
+ pages. The fallbacks are the engine defaults, for a page with no theme tag.
189
+ The multiples here must match Studio::TypeScale::ROLES
190
+ (test/lib/type_scale_contract_test.rb). */
191
+
192
+ @utility type-dense {
193
+ --type-base: var(--type-dense-base, 14px);
194
+ }
195
+
196
+ @utility type-title {
197
+ font-size: calc(var(--type-base, 16px) * 6);
198
+ font-weight: var(--type-display-weight, 800);
199
+ line-height: 1.05;
200
+ }
201
+
202
+ @utility type-heading {
203
+ font-size: calc(var(--type-base, 16px) * 3);
204
+ font-weight: var(--type-display-weight, 800);
205
+ line-height: 1.1;
206
+ }
207
+
208
+ @utility type-subhead {
209
+ font-size: calc(var(--type-base, 16px) * 1.5);
210
+ font-weight: var(--type-text-weight, 400);
211
+ line-height: 1.25;
212
+ }
213
+
214
+ @utility type-body {
215
+ font-size: calc(var(--type-base, 16px) * 1);
216
+ font-weight: var(--type-text-weight, 400);
217
+ line-height: 1.5;
218
+ }
219
+
220
+ @utility type-fine {
221
+ font-size: calc(var(--type-base, 16px) * 0.75);
222
+ font-weight: var(--type-text-weight, 400);
223
+ line-height: 1.4;
224
+ }
225
+
182
226
  /* -- Scrims: tints and shades ----------------------------------------------
183
227
  A scrim is the wash laid over media (a photo, a video) so text reads on it.
184
228
  It comes in two TONES, and the tone is a design decision, not a colour pick:
@@ -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