studio-engine 0.137.0 → 0.138.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +27 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +228 -0
  5. data/app/controllers/concerns/studio/connection_flow.rb +1 -1
  6. data/app/controllers/sessions_controller.rb +12 -3
  7. data/app/controllers/studio/integrations_controller.rb +43 -0
  8. data/app/controllers/studio/local_reviews_controller.rb +12 -0
  9. data/app/javascript/studio/carousel.js +66 -0
  10. data/app/javascript/studio/controllers/carousel_controller.js +203 -1
  11. data/app/models/concerns/studio/account.rb +143 -0
  12. data/app/models/concerns/studio/omniauth_user.rb +9 -17
  13. data/app/models/error_log.rb +14 -5
  14. data/app/views/studio/_carousel.html.erb +58 -14
  15. data/app/views/studio/integrations/_card.html.erb +50 -0
  16. data/app/views/studio/integrations/index.html.erb +22 -0
  17. data/app/views/studio/integrations/show.html.erb +24 -0
  18. data/app/views/style/_tricks.html.erb +15 -0
  19. data/config/studio_tricks.yml +7 -0
  20. data/db/migrate/20261006120001_ensure_error_logs_table.rb +3 -1
  21. data/db/migrate/20261006120002_ensure_theme_settings_table.rb +3 -2
  22. data/db/migrate/20261108160000_require_slugs_on_engine_tables.rb +119 -0
  23. data/db/migrate/20261108160001_add_engine_state_checks.rb +94 -0
  24. data/db/migrate/20261108170000_add_users_role_known_check.rb +69 -0
  25. data/lib/generators/studio/connections/connections_generator.rb +10 -0
  26. data/lib/studio/connections.rb +53 -12
  27. data/lib/studio/integrations/egnyte.rb +69 -0
  28. data/lib/studio/integrations/microsoft_365.rb +63 -0
  29. data/lib/studio/integrations/quickbooks.rb +86 -0
  30. data/lib/studio/integrations.rb +265 -0
  31. data/lib/studio/node_tooling/README.md +9 -1
  32. data/lib/studio/node_tooling/e2e_helpers.js +93 -1
  33. data/lib/studio/node_tooling/playwright_base.js +51 -4
  34. data/lib/studio/roles.rb +17 -0
  35. data/lib/studio/version.rb +1 -1
  36. data/lib/studio.rb +59 -1
  37. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 448f5dc2b80db8f30bb9d05849d581065592b28f2189062424bfd26c25acdc63
4
- data.tar.gz: 68b40d7dd6cbc3bc2f8c9e19f409755e1c7e1d1ece531e1689542f32fdeccb7c
3
+ metadata.gz: cb1274e13e7eca7030835c5ff43a48a897f01e723273ec60e4f5eb334e190c85
4
+ data.tar.gz: a8e5bb472135b9f72a8662026dac450d2b0830dd30d6913a504c0ced09e57dd2
5
5
  SHA512:
6
- metadata.gz: 93377b901882007a34b2bb4ffc855ed5078cfb02e8cc35a58e9afe102df2d832d046f7531d95996a4899259a6bbfe0359d6a64ebd32370e7ca8fb274a27cd3bb
7
- data.tar.gz: 4781ef35c39d5cdde579d71cf8ad93e26abb5896895480aece83c315b2e944fc48b959b09589c34e6c62354dbe2d7aa1a7746980dafd299d24023e80e74a9ae8
6
+ metadata.gz: 4184a55e11cf2e7c3146c73520878a5e1edbc6802bcb19eb9a3c61b09d6d73912a867c98bbd56772d664dc4bf2bace1a152a864c36c1447102bf83d7d0d444df
7
+ data.tar.gz: d06d58547262873513c597eb54f8313e76ac8bbbc3dcc7d6693a5a12ffbc23b4c08eeb782a94edf384d4af09bc3f83eda27845f5fc5a112af339c8ada5269bc0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.138.0 — 2026-11-08
8
+
9
+ ### Added
10
+
11
+ - **`studio/_carousel` takes `style: :cascade`, a 3D stacked carousel.** The open slide sits in front at full size; the slides either side step back behind it, each step smaller, deeper and dimmer, turned a little toward the centre. `visible: 5` (the default) shows two each side, `visible: 3` one, and a region under 36rem wide shows one whatever it asks. Arrows, dots, keys, swipe or drag, a click on a side slide and keyboard focus bring a slide to the front; `loop:`, `start:`, `autoplay:` and `caption:` work as on the spotlight, and a looping cascade needs no clones. Reduced motion lays it flat as a spotlight row. `:standard` and `:spotlight` render byte for byte as before. A host adopts it by changing `style: :spotlight` to `style: :cascade`; `visible:` on any other style raises.
12
+ - **Shared reveal waits for page transition specs.** `e2e_helpers.js` adds `watchReveal`, `revealed`, `settled`, `arrivalSettled`, `frames`, `sessionLog` and `logOnRed`, moved from the engine's full-load spec. They wait on the new page's `pagereveal` and its view transition, never on a pause, and a red prints and attaches the event log. A host's own transition spec can drop its copies and require these from `node_modules/.studio-engine/e2e_helpers.js`.
13
+ - **Integrations registry: QuickBooks, Egnyte and Microsoft 365 connection definitions, and an admin page.** The engine now ships a connection definition for each (URLs, scopes, token behaviour, a one-call describe of the account; no reader). QuickBooks keeps its access token until 5 minutes before it expires or a 401, then refreshes once under the row lock and stores the rotated refresh token; it captures Intuit's `intuit_tid` header on every call (new generic `trace_header:` provider option: logged per call, `provider.last_trace_id`, and on every raised error as `trace_id` and in its message). A host enables one in an initializer with `Studio.enable_integration :quickbooks, env: { client_id: "...", client_secret: "..." }`, naming its own env vars, and mounts `Studio.draw_integrations_routes(self)`: `/admin/integrations` lists each status and Connect, and `/admin/integrations/<key>` shows its connections, Disconnect, the redirect URI to register (`/admin/integrations/<key>/callback`) and the host's own panel (`panel:`). Stand-ins walk a connect end to end in development and test. See `docs/INTEGRATIONS.md`.
14
+ - **`Studio::Account`, the engine-owned account contract.** `include Studio::Account` in `User` gives `admin?`, a default `display_name`, `normalizes :email` (blank stored as NULL), `validates :role, inclusion:` against the new `Studio::Roles` (`viewer`, `member`, `admin`; a host may narrow it with `studio_roles`, never widen it), `Sluggable` and `Studio::OmniauthUser`. `Studio::Account.find_by_email` is the one safe lookup by address (blank finds nobody, `LOWER(email)`), and `Studio::Account.race_winner` is the race rescue for a host's own `from_omniauth` (verified address only; logs to `ErrorLog.capture!` when nothing matches). The new host migration `AddUsersRoleKnownCheck` installs CHECK `users_role_known` and NOT NULL, skips an app that already has the CHECK, and refuses on a stray role. Nothing changes until an app includes the concern or installs the migration. See docs/USER_CONTRACT.md, "The account concern". (The audit called it `Studio::User`; that name would shadow the host's `User` inside `module Studio`.)
15
+
16
+ ### Changed
17
+
18
+ - **A local-review provisioning miss is logged to ErrorLog.** `Studio::LocalReviewsController` still mints the link when it cannot provision the reviewer, and now records the reason with `ErrorLog.capture!` as well as the Rails log. The likeliest cause after `Studio::Account`: `Studio.local_review_role` names a role the app's list refuses.
19
+ - **The engine's own tables meet the hub's database standard.** Two new migrations, for every host. `RequireSlugsOnEngineTables` makes `slug` NOT NULL with a unique index on `error_logs`, `theme_settings`, `studio_geo_settings` and `studio_site_identities`. It first fills a NULL or blank slug the way the model writes one (`error-log-<id>`, `theme-<app>`, `geo-<app>`, `site-identity-<app>`), and gives a duplicate after the first `-<id>`. `AddEngineStateChecks` adds CHECK `studio_links_kind_known` (`Studio::LinkToken::KINDS`) and `studio_knowledge_docs_status_known` (`Studio::KnowledgeDoc::STATUSES`), and stops with the stray values named if a row holds another value. Production held no NULL slug, no duplicate and no stray value on 2026-11-08. `ErrorLog` now includes `Sluggable`, so a bare `ErrorLog.create!` gets `error-log-<id>` too, and a host's own `update_column(:slug, "error-log-#{log.id}")` after a create may go. To adopt, use two releases, model first. Release one bumps the gem only: the new `ErrorLog` writes a slug on the old nullable column. Release two runs `bin/rails studio_engine:install:migrations && bin/rails db:migrate` and commits `db/schema.rb`. In one release, old dynos insert a NULL slug until the restart, and a failed release or a `heroku rollback` leaves old code on the NOT NULL column, so every `ErrorLog` write fails; undo it with `bin/rails db:migrate:down VERSION=20261108160000` before the rollback. A host whose own state registry lists `studio_knowledge_docs.status` as unconstrained (the hub's `StringStates`) moves it to a checked entry.
20
+
21
+ ### Fixed
22
+
23
+ - **A local e2e run no longer reuses another tree's server.** `playwright_base.js` reuses a server already on the e2e port only when its working directory (read with `lsof`) is inside this tree. A sibling desk's server on the shared default port is refused, with a warning that names its root; give the run its own port with `E2E_PORT`. CI is unchanged: it never reuses a server.
24
+ - **Connect's refusal of a mistyped account now reads "That is not a valid <label> account."** It read "not a Egnyte account" for a label that starts with a vowel. A host test that matched the old text matches the new one.
25
+
26
+ ### Security
27
+
28
+ - **The engine's sign-in doors find nobody for a blank email.** `SessionsController#create` and the SSO "Continue as" path looked the account up with `User.find_by(email: ...)`. With no address that is `find_by(email: nil)`, which matches any account without an email (a wallet or guest sign-up): the password door handed that account to `authenticate`, and the SSO path signed it in. Both now use `Studio::Account.find_by_email`, which also matches case-insensitively. Nothing for a consumer to change.
29
+
7
30
  ## 0.137.0 — 2026-11-08
8
31
 
9
32
  ### Added
data/README.md CHANGED
@@ -51,7 +51,8 @@ resolved.
51
51
  - **Voice profile**: an opt-in row on `/profile` ("Set up your voice"): the person reads three phrases with a live level meter, their device turns each take into a speaker embedding (WeSpeaker via transformers.js), and only the averaged vector is saved on their user, never audio. `Studio::VoiceMatch.identify(embedding, among: User.all, model:)` answers who is speaking. Turn it on with `config.voice_profile = true` after installing the migration. See [`docs/VOICE_PROFILE.md`](docs/VOICE_PROFILE.md).
52
52
  - **Voice capture**: the microphone chip (names the microphone a page records from, picks another, Chrome help dialog), one level meter, and a record button that refuses a silent take and hands a heard one to the page as a `studio:voice-recorder:take` event with the blob; it uploads nothing. Render `studio/voice/microphone_chip` and `studio/voice/recorder`. See [`docs/VOICE_CAPTURE.md`](docs/VOICE_CAPTURE.md).
53
53
  - **Voice transcription**: `POST /voice/takes` (opt-in, `config.voice_transcription = true`) turns a signed-in user's take into `{text, speaker, score}`: a mock-first transcriber (OpenAI `gpt-4o-transcribe` in production, a mock elsewhere, a no-live-calls trap in every test environment) and `Studio::VoiceMatch` against the user's own saved voice. Nothing is kept. See [`docs/VOICE_TRANSCRIPTION.md`](docs/VOICE_TRANSCRIPTION.md).
54
- - **Connections**: generic OAuth 2.0 tooling to connect a third-party account. A host declares each provider with `Studio.define_connection` (endpoints, static or per-account; scopes; form or HTTP Basic client auth; a rotating, cached or client-credentials refresh policy). The engine gives it `Studio::Connection` (one table, tokens encrypted, `bin/rails g studio:connections`), the connect, callback and disconnect actions (`Studio::ConnectionFlow`) behind the host's admin guard, `Studio.draw_connection_routes`, an exact-key log filter, and a stand-in for the e2e lane. The engine names no provider. See [`docs/CONNECTIONS.md`](docs/CONNECTIONS.md).
54
+ - **Connections**: generic OAuth 2.0 tooling to connect a third-party account. A host declares each provider with `Studio.define_connection` (endpoints, static or per-account; scopes; form or HTTP Basic client auth; a rotating, cached or client-credentials refresh policy). The engine gives it `Studio::Connection` (one table, tokens encrypted, `bin/rails g studio:connections`), the connect, callback and disconnect actions (`Studio::ConnectionFlow`) behind the host's admin guard, `Studio.draw_connection_routes`, an exact-key log filter, and a stand-in for the e2e lane. The tooling names no provider; the engine's own definitions are the Integrations below. See [`docs/CONNECTIONS.md`](docs/CONNECTIONS.md).
55
+ - **Integrations**: ready connection definitions on that tooling for QuickBooks, Egnyte and Microsoft 365: the URLs, scopes, token behaviour and a minimal describe, never a reader. A host turns one on with `Studio.enable_integration :quickbooks, env: { client_id: "...", client_secret: "..." }` (env var names) and mounts the admin page with `Studio.draw_integrations_routes(self)`: a list with each status and Connect, and one page per integration with Disconnect and a host panel for the host's own live read. See [`docs/INTEGRATIONS.md`](docs/INTEGRATIONS.md).
55
56
  - **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
56
57
 
57
58
  ## Tailwind
@@ -257,6 +258,8 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
257
258
 
258
259
  For each connection provider a host declares, `Studio.draw_connection_routes(self, :files, path: "admin/files")` adds its connect (`GET`/`POST /admin/files/connect`), callback (`GET /admin/files/callback`, the redirect URI to register) and disconnect (`DELETE /admin/files/connections/:id`) routes. Nothing is drawn for a provider the host has not declared. They need the `studio_connections` table (`bin/rails g studio:connections`; [`docs/CONNECTIONS.md`](docs/CONNECTIONS.md)).
259
260
 
261
+ `Studio.draw_integrations_routes(self)` draws the admin integrations page at `GET /admin/integrations` and, for each integration enabled with `Studio.enable_integration`, its page (`GET /admin/integrations/<key>`) and its connection routes under that path. QuickBooks's callback is then `/admin/integrations/quickbooks/callback` ([`docs/INTEGRATIONS.md`](docs/INTEGRATIONS.md)).
262
+
260
263
  **Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
261
264
 
262
265
  Set `Studio.agent_chat_souls = %w[alex]` to add the shared agent chat's proxy (`POST /agent_chat/messages`, `GET /agent_chat/turns`), which forwards to the McRitchie Studio hub with the app's `AGENT_CHAT_TOKEN`; then `render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: ...` on any page, with `voice: true` for hold to talk in the composer ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md)). To open that chat in a modal from any button, render `studio/agent_chat/talk_modal` once and mark the buttons with `studio_talk_to_agent_link` ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md), "Talk to an agent in a modal").
@@ -395,6 +398,29 @@ again from the first.
395
398
  <% end %>
396
399
  ```
397
400
 
401
+ `style: :cascade` is a 3D stack. The open slide sits in front at full size, and the slides
402
+ either side step back behind it: each step further aside, smaller, deeper (`perspective`,
403
+ `translateZ`), dimmer and turned a little toward the centre, with the open slide on top.
404
+ `visible: 5` (the default) shows two each side and `visible: 3` one; a region under 36rem
405
+ wide shows one each side whatever it asks, so a phone keeps its cards legible. `visible:`
406
+ on another style raises. The row does not scroll: each slide wears `data-cascade-step`
407
+ (`-2` to `2` in view, `before` and `after` past the window, hidden), which the partial writes
408
+ for the first paint and the controller rewrites on a move. The arrows, dots, keys
409
+ (arrows, Home, End), a swipe or drag, autoplay, a click on a side slide (which opens
410
+ nothing) and keyboard focus landing in a side slide bring that slide to the front, and the
411
+ status line says "Slide N of M". `start:`, `caption:` (under the open slide only),
412
+ `autoplay:` and `loop:` behave as on the spotlight; a looping cascade needs no clones,
413
+ since the steps wrap round the set. Under reduced motion the stack lays flat as a spotlight
414
+ row, the neighbours only dimmed, and a move swaps at once. A controller that fails leaves
415
+ a plain scrolling row.
416
+
417
+ ```erb
418
+ <%= render layout: "studio/carousel",
419
+ locals: { label: "Portfolio", items: @apps, style: :cascade, autoplay: 6, loop: true } do |app| %>
420
+ <%= render "apps/card", app: app %>
421
+ <% end %>
422
+ ```
423
+
398
424
  ### The "at" time stamp — `at_time_tag`
399
425
 
400
426
  Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
@@ -1112,6 +1112,234 @@ body.modal-open .studio-app-banner {
1112
1112
  }
1113
1113
  }
1114
1114
 
1115
+ /* -- The cascade carousel (studio/_carousel, style: :cascade) ----------------------
1116
+ A 3D stack. The open slide sits in front at full size; the slides either side step
1117
+ back behind it, each step further aside, smaller, deeper and dimmer, turned a
1118
+ little toward the centre. visible: 5 (the default) shows two each side, visible: 3
1119
+ one; a region under 36rem wide shows one each side whatever it asks, so a phone
1120
+ keeps its cards legible.
1121
+
1122
+ THE ROW DOES NOT SCROLL. Every slide sits in one grid cell, so the cell is as tall
1123
+ as the tallest slide, and each wears data-cascade-step: its place from the open
1124
+ slide, -2 to 2 in view, "before" and "after" past the window (hidden). The partial
1125
+ writes the first paint's steps and the controller rewrites them on a move; this
1126
+ block maps a step to --cascade-x (signed) and --cascade-d (its depth) and lays the
1127
+ slide out from the two, with a transition. The track clips sideways, so the stack
1128
+ never widens the page. --carousel-cascade-reach tells the controller how many
1129
+ steps each side are in view.
1130
+
1131
+ A slide that crosses the stack round a looping set's seam (data-cascade-jump)
1132
+ takes no glide; it fades in at its new place. A drag nudges the stack after the
1133
+ pointer (--carousel-cascade-drag) and settles on release.
1134
+
1135
+ UNDER REDUCED MOTION nothing turns, deepens, scales or glides: the stack lays flat
1136
+ as a spotlight row does, the neighbours only dimmed, and a move swaps at once.
1137
+
1138
+ IF THE CONTROLLER FAILS (data-studio-controller-failed) the stack is a plain
1139
+ scrolling row, so every slide can still be reached. */
1140
+ @keyframes carousel-cascade-in {
1141
+ from {
1142
+ opacity: 0;
1143
+ }
1144
+ }
1145
+
1146
+ @utility carousel-cascade {
1147
+ & > .carousel-track {
1148
+ --carousel-cascade-reach: 2;
1149
+ --cascade-w: min(34cqi, 22rem);
1150
+ --cascade-shift: 0.52;
1151
+ --cascade-scale: 0.12;
1152
+ --cascade-depth: 6rem;
1153
+ --cascade-turn: 10deg;
1154
+ --cascade-dim: 0.22;
1155
+ display: grid;
1156
+ grid-template-columns: minmax(0, 1fr);
1157
+ justify-items: center;
1158
+ align-items: start;
1159
+ gap: 0;
1160
+ overflow: visible;
1161
+ overflow-x: clip;
1162
+ scroll-snap-type: none;
1163
+ perspective: 1400px;
1164
+ perspective-origin: 50% 40%;
1165
+ touch-action: pan-y;
1166
+ padding-block: 0.5rem 0.75rem;
1167
+ -webkit-mask-image: none;
1168
+ mask-image: none;
1169
+ }
1170
+
1171
+ &[data-carousel-visible="3"] > .carousel-track {
1172
+ --carousel-cascade-reach: 1;
1173
+ --cascade-w: min(46cqi, 26rem);
1174
+ --cascade-shift: 0.62;
1175
+ }
1176
+
1177
+ /* A narrow region shows one each side, on wider cards. */
1178
+ @container (width < 36rem) {
1179
+ & > .carousel-track {
1180
+ --carousel-cascade-reach: 1;
1181
+ --cascade-w: 64cqi;
1182
+ --cascade-shift: 0.44;
1183
+ --cascade-depth: 3.5rem;
1184
+ }
1185
+ }
1186
+
1187
+ &[data-carousel-dragging] > .carousel-track {
1188
+ cursor: grabbing;
1189
+ user-select: none;
1190
+ }
1191
+
1192
+ & > .carousel-track > .carousel-slide {
1193
+ --cascade-x: 0;
1194
+ --cascade-d: 0;
1195
+ grid-area: 1 / 1;
1196
+ flex: none;
1197
+ width: var(--cascade-w);
1198
+ scroll-snap-align: none;
1199
+ display: flex;
1200
+ flex-direction: column;
1201
+ z-index: calc(10 - var(--cascade-d));
1202
+ transform:
1203
+ translateX(calc(var(--cascade-x) * var(--cascade-w) * var(--cascade-shift)))
1204
+ translateZ(calc(var(--cascade-d) * -1 * var(--cascade-depth)))
1205
+ rotateY(calc(var(--cascade-x) * -1 * var(--cascade-turn)))
1206
+ scale(calc(1 - var(--cascade-d) * var(--cascade-scale)));
1207
+ translate: var(--carousel-cascade-drag, 0px) 0;
1208
+ opacity: 1;
1209
+ transition:
1210
+ transform 480ms cubic-bezier(0.22, 0.8, 0.26, 1),
1211
+ translate 480ms cubic-bezier(0.22, 0.8, 0.26, 1),
1212
+ opacity 320ms ease,
1213
+ visibility 0s linear 0s;
1214
+ -webkit-user-drag: none;
1215
+ }
1216
+
1217
+ & > .carousel-track > .carousel-slide :is(a, img) {
1218
+ -webkit-user-drag: none;
1219
+ }
1220
+
1221
+ /* A side slide is a target: a click brings it to the front. */
1222
+ &[data-carousel-ready] > .carousel-track > .carousel-slide:not([data-current]) {
1223
+ cursor: pointer;
1224
+ }
1225
+
1226
+ &[data-carousel-dragging] > .carousel-track > .carousel-slide {
1227
+ transition-property: transform, opacity, visibility;
1228
+ }
1229
+
1230
+ & > .carousel-track > [data-cascade-step="-1"] { --cascade-x: -1; --cascade-d: 1; }
1231
+ & > .carousel-track > [data-cascade-step="1"] { --cascade-x: 1; --cascade-d: 1; }
1232
+ & > .carousel-track > [data-cascade-step="-2"] { --cascade-x: -2; --cascade-d: 2; }
1233
+ & > .carousel-track > [data-cascade-step="2"] { --cascade-x: 2; --cascade-d: 2; }
1234
+
1235
+ /* Past the window: one more step out, hidden. visibility waits for the fade. */
1236
+ & > .carousel-track > :is([data-cascade-step="before"], [data-cascade-step="after"]) {
1237
+ --cascade-d: 3;
1238
+ opacity: 0;
1239
+ visibility: hidden;
1240
+ pointer-events: none;
1241
+ transition-delay: 0s, 0s, 0s, 320ms;
1242
+ }
1243
+
1244
+ & > .carousel-track > [data-cascade-step="before"] { --cascade-x: -3; }
1245
+ & > .carousel-track > [data-cascade-step="after"] { --cascade-x: 3; }
1246
+
1247
+ /* Three in view: the second step out is past the window too. */
1248
+ &[data-carousel-visible="3"] > .carousel-track > :is([data-cascade-step="-2"], [data-cascade-step="2"]) {
1249
+ opacity: 0;
1250
+ visibility: hidden;
1251
+ pointer-events: none;
1252
+ transition-delay: 0s, 0s, 0s, 320ms;
1253
+ }
1254
+
1255
+ @container (width < 36rem) {
1256
+ & > .carousel-track > :is([data-cascade-step="-2"], [data-cascade-step="2"]) {
1257
+ opacity: 0;
1258
+ visibility: hidden;
1259
+ pointer-events: none;
1260
+ transition-delay: 0s, 0s, 0s, 320ms;
1261
+ }
1262
+ }
1263
+
1264
+ /* Round the seam: no glide across the stack, a fade in at the new place. */
1265
+ & > .carousel-track > .carousel-slide[data-cascade-jump] {
1266
+ transition: none;
1267
+ animation: carousel-cascade-in 320ms ease both;
1268
+ }
1269
+
1270
+ & > .carousel-track > .carousel-slide[data-cascade-jump]:is([data-cascade-step="before"], [data-cascade-step="after"]) {
1271
+ animation: none;
1272
+ }
1273
+
1274
+ /* The dim: brightness and colour fall with depth, so a step back reads as further
1275
+ away on a light page and a dark one, glass or solid, and nothing behind shows
1276
+ through the card in front. */
1277
+ & .carousel-cascade-body {
1278
+ flex: 1 1 auto;
1279
+ min-height: 0;
1280
+ filter: brightness(calc(1 - var(--cascade-d) * var(--cascade-dim))) saturate(calc(1 - var(--cascade-d) * 0.3));
1281
+ transition: filter 320ms ease;
1282
+ }
1283
+
1284
+ & .carousel-slide[data-current] .carousel-cascade-body {
1285
+ filter: none;
1286
+ }
1287
+
1288
+ & .carousel-caption {
1289
+ margin-top: 0.75rem;
1290
+ text-align: center;
1291
+ @apply text-sm text-body;
1292
+ transition: opacity 300ms ease;
1293
+ }
1294
+
1295
+ & .carousel-slide:not([data-current]) .carousel-caption {
1296
+ opacity: 0;
1297
+ }
1298
+
1299
+ @media (prefers-reduced-motion: reduce) {
1300
+ & > .carousel-track > .carousel-slide {
1301
+ transform: translateX(calc(var(--cascade-x) * (var(--cascade-w) + 0.75rem)));
1302
+ translate: none;
1303
+ transition: none !important;
1304
+ animation: none !important;
1305
+ }
1306
+
1307
+ & .carousel-cascade-body,
1308
+ & .carousel-caption {
1309
+ transition: none !important;
1310
+ }
1311
+
1312
+ & .carousel-slide:not([data-current]) .carousel-cascade-body {
1313
+ filter: none;
1314
+ opacity: 0.45;
1315
+ }
1316
+ }
1317
+
1318
+ /* A controller that failed leaves a plain scrolling row: every slide in reach. */
1319
+ &[data-studio-controller-failed] > .carousel-track {
1320
+ display: flex;
1321
+ gap: 1rem;
1322
+ overflow-x: auto;
1323
+ scroll-snap-type: x mandatory;
1324
+ perspective: none;
1325
+ }
1326
+
1327
+ &[data-studio-controller-failed] > .carousel-track > .carousel-slide {
1328
+ flex: 0 0 var(--cascade-w);
1329
+ scroll-snap-align: center;
1330
+ transform: none;
1331
+ translate: none;
1332
+ opacity: 1;
1333
+ visibility: visible;
1334
+ pointer-events: auto;
1335
+ transition: none;
1336
+ }
1337
+
1338
+ &[data-studio-controller-failed] .carousel-cascade-body {
1339
+ filter: none;
1340
+ }
1341
+ }
1342
+
1115
1343
  /* -- The section header (studio/_section_header, docs/PAGE_HEADER.md) --------
1116
1344
  A section's title that sticks under the rest of the stack, inside its own <section>,
1117
1345
  so the next section's header takes its place with no scroll script. At rest it reads as
@@ -49,7 +49,7 @@ module Studio
49
49
  account = nil
50
50
  if provider.account_source == :connect
51
51
  account = provider.normalize_account(params[provider.account_param]) or
52
- return redirect_to(back, alert: "That is not a #{provider.label} account. Check what you entered, then press Connect again.")
52
+ return redirect_to(back, alert: "That is not a valid #{provider.label} account. Check what you entered, then press Connect again.")
53
53
  end
54
54
  url = provider.authorize_url(redirect_uri: connection_callback_url, state: new_connection_state, account:)
55
55
  session[connection_session_key(:account)] = account
@@ -5,7 +5,10 @@ class SessionsController < ApplicationController
5
5
  end
6
6
 
7
7
  def create
8
- user = User.find_by(email: params[:email])
8
+ # Studio::Account.find_by_email, never find_by(email:): a form posted with
9
+ # no address made that find_by(email: nil), which matches any account with
10
+ # no email (a wallet or guest sign-up) and handed it to `authenticate`.
11
+ user = Studio::Account.find_by_email(User, params[:email])
9
12
  if user&.authenticate(params[:password])
10
13
  set_app_session(user)
11
14
  redirect_to root_path, notice: "Welcome back, #{user.display_name}!"
@@ -49,10 +52,16 @@ class SessionsController < ApplicationController
49
52
  private
50
53
 
51
54
  def authenticate_sso_user!
52
- user = User.find_by(email: session[:sso_email])
55
+ # sso_continue checks the address is present first; this method does not
56
+ # trust that. A blank one finds nobody and signs nobody in: find_by(email:
57
+ # nil) would match any account without an email and sign in as it.
58
+ email = Studio::Account.normalize_email(session[:sso_email])
59
+ return redirect_to(login_path, alert: "Could not continue session. Please log in.") unless email
60
+
61
+ user = Studio::Account.find_by_email(User, email)
53
62
  unless user
54
63
  user = User.new(
55
- email: session[:sso_email],
64
+ email: email,
56
65
  name: session[:sso_name],
57
66
  provider: session[:sso_provider],
58
67
  uid: session[:sso_uid]
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The admin integrations page (docs/INTEGRATIONS.md): every integration the
5
+ # host enabled with Studio.enable_integration, its status and its Connect,
6
+ # and one page per integration with its connections, Disconnect, and the
7
+ # host's own panel. Studio.draw_integrations_routes points here.
8
+ #
9
+ # It includes Studio::ConnectionFlow, so connect, callback and disconnect are
10
+ # the connection tooling's own, and the host's admin guard
11
+ # (Studio.connection_admin_guard) runs before EVERY action here, the list
12
+ # and the pages included. It inherits the host's ApplicationController: its
13
+ # layout, session, current_user and Studio::ErrorHandling apply.
14
+ class IntegrationsController < ::ApplicationController
15
+ include Studio::ConnectionFlow
16
+
17
+ helper_method :integration_path_for, :integration_connect_path, :integration_callback_url,
18
+ :integration_disconnect_path, :integrations_index_path
19
+
20
+ def index
21
+ @integrations = Studio::Integrations.all
22
+ end
23
+
24
+ def show
25
+ @integration = Studio::Integrations.fetch(params[:studio_connection])
26
+ @connections = @integration.connections
27
+ end
28
+
29
+ private
30
+
31
+ def integration_route(integration) = Studio::Connections.route(integration.key)
32
+
33
+ def integration_path_for(integration) = public_send(:"#{integration_route(integration).as}_path")
34
+ def integration_connect_path(integration) = public_send(:"#{integration_route(integration).as}_connect_path")
35
+ def integration_callback_url(integration) = public_send(:"#{integration_route(integration).as}_callback_url")
36
+
37
+ def integration_disconnect_path(integration, connection)
38
+ public_send(:"#{integration_route(integration).as}_disconnect_path", connection)
39
+ end
40
+
41
+ def integrations_index_path = "/#{Studio::Integrations.mount_path}"
42
+ end
43
+ end
@@ -115,9 +115,21 @@ module Studio
115
115
  Rails.logger.warn(
116
116
  "[Studio::LocalReviewsController] could not provision #{email}: #{e.class}: #{e.message}"
117
117
  )
118
+ # An ErrorLog too, so the miss is on the page an operator reads. The
119
+ # likeliest cause: Studio.local_review_role names a role this app's
120
+ # Studio::Account list refuses.
121
+ log_provisioning_failure(e)
118
122
  nil
119
123
  end
120
124
 
125
+ # Best-effort as well: a host whose error_logs table is missing or broken
126
+ # must not turn a logged miss into a failed mint.
127
+ def log_provisioning_failure(error)
128
+ ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
129
+ rescue StandardError => e
130
+ Rails.logger.warn("[Studio::LocalReviewsController] could not log the miss: #{e.class}")
131
+ end
132
+
121
133
  # A brand-new reviewer goes through the host's own new-user hook first, so
122
134
  # the account this endpoint creates is shaped like every other account in
123
135
  # the app (usernames, defaults, associations) — then the role is stamped on
@@ -246,3 +246,69 @@ export function loopPositionText(first, shown, total) {
246
246
  const head = first + 1 === total ? `Slides ${total}` : `Slides ${first + 1} to ${total}`
247
247
  return `${head} and ${over === 1 ? "1" : `1 to ${over}`} of ${total}`
248
248
  }
249
+
250
+ // THE CASCADE (style: :cascade, class carousel-cascade). The row does not scroll:
251
+ // every slide sits in one grid cell and wears data-cascade-step, its place from the
252
+ // open slide, which engine.css turns into the 3D stack. A STEP is signed: 0 is the
253
+ // open slide, -1 and 1 its neighbours, and so on. CASCADE_REACH steps either side
254
+ // are in view; past them a slide's slot is "before" or "after", hidden.
255
+ //
256
+ // app/views/studio/_carousel.html.erb writes the first paint's slots with the same
257
+ // rule, so the two must agree (test/views/carousel_render_test.rb pins the partial).
258
+ export const CASCADE_REACH = 2
259
+
260
+ // A slide's step from the open one. A looping set has no ends, so the step is the
261
+ // short way round; a tie (an even set's far side) goes to the later side.
262
+ export function cascadeStep(index, active, total, loop = false) {
263
+ const step = index - active
264
+ if (!loop || !(total > 1)) return step
265
+ const round = wrapIndex(step, total)
266
+ return round > total / 2 ? round - total : round
267
+ }
268
+
269
+ // Every slide's step, in row order.
270
+ export function cascadeSteps(total, active, loop = false) {
271
+ return Array.from({ length: Math.max(0, total) }, (_, index) => cascadeStep(index, active, total, loop))
272
+ }
273
+
274
+ // The data-cascade-step a step is written as: the step itself in view, "before" or
275
+ // "after" past the reach.
276
+ export function cascadeSlot(step, reach = CASCADE_REACH) {
277
+ if (step < -reach) return "before"
278
+ if (step > reach) return "after"
279
+ return String(step)
280
+ }
281
+
282
+ // The cascade's reach from its view: 5 slides in view reach 2 each side, 3 reach 1.
283
+ export function cascadeReach(visible) {
284
+ return Number(visible) === 3 ? 1 : CASCADE_REACH
285
+ }
286
+
287
+ // Which slides JUMP on a move from one set of steps to the next: the ones that cross
288
+ // round a looping set's seam (the far left slide becoming the far right one) instead
289
+ // of shifting with the rest. They take no glide across the stack. Every other slide
290
+ // moves by the same shift, the open slide's old step turned round.
291
+ export function cascadeJumps(before, after, active) {
292
+ const shift = -(before[active] ?? 0)
293
+ return after.map((step, index) => step - before[index] !== shift)
294
+ }
295
+
296
+ // How many slides a drag of `dx` pixels moves, against `width` pixels a step: none
297
+ // under the threshold, else at least one, the open slide following the pointer
298
+ // (a drag to the left brings the next slides in, so it moves forward).
299
+ export const DRAG_THRESHOLD = 40
300
+ export function dragSteps(dx, width) {
301
+ if (!(Math.abs(dx) >= DRAG_THRESHOLD)) return 0
302
+ const per = width > 0 ? width : 1
303
+ const count = Math.max(1, Math.round(Math.abs(dx) / per))
304
+ return dx < 0 ? count : -count
305
+ }
306
+
307
+ // The slide a drag or a click takes a cascade to, from the open one: held to the
308
+ // ends of a set that does not loop, round the set on one that does.
309
+ export function cascadeTarget(active, by, total, loop = false) {
310
+ if (!(total > 0)) return 0
311
+ const to = active + by
312
+ if (loop) return wrapIndex(to, total)
313
+ return Math.min(total - 1, Math.max(0, to))
314
+ }