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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +43 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +323 -0
- data/app/assets/tailwind/studio_engine/engine.css +44 -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/helpers/studio_theme_helper.rb +1 -1
- 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/type_scale.rb +64 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +63 -0
- data/studio-engine.gemspec +8 -0
- metadata +75 -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,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
|
-
|
|
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
|