# Studio Engine Shared Rails engine for McRitchie apps. Provides authentication, error handling, dynamic theming, and common concerns used by [McRitchie Studio](https://app.mcritchie.studio) and [Turf Monster](https://app.turfmonster.media). > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/McRitchie-Studio/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/McRitchie-Studio/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery. ## Installation ```ruby # Gemfile — install from RubyGems (recommended) gem "studio-engine", "~> 0.56" # EXAMPLE — pin the floor YOUR app needs ``` Then `bundle install`. That number is an example, not a statement about the current release: this README deliberately makes no such claim, because a hand-written one rots silently — it read `v0.6.1` for fifty minors. For what is live, see [RubyGems](https://rubygems.org/gems/studio-engine); for what changed, [`CHANGELOG.md`](./CHANGELOG.md). Pin the floor your app actually needs rather than copying the example. Each consumer pins its own and records WHY beside it, and they differ on purpose. A two-segment `~>` admits everything under `1.0`, so the pin documents the floor rather than constraining the resolve — read the `Gemfile.lock` for what really resolved. > Published to RubyGems as of v0.4.0 (2026-05-17). New installs should use the > RubyGems form, which all three consumer Rails apps (`mcritchie-studio`, > `turf-monster`, `mcritchie-industries`) already use. ## What It Provides - **Authentication**: Passwordless magic-link auth, optional password auth, Google OAuth via OmniAuth, Solana wallet sign-in, and optional one-way SSO patterns - **Error handling**: `Studio::ErrorHandling` concern with `rescue_and_log`, `ErrorLog` model with `capture!`, error log viewer at `/error_logs` - **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). - **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`. - **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions. - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions. - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults - **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). - **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). ## Configuration Each consuming app configures the engine in `config/initializers/studio.rb`: ```ruby Studio.configure do |config| config.app_name = "My App" config.session_key = :my_app_user_id config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" } config.auth_methods = %i[magic_link google] config.registration_params = [:name, :email] config.mailer_from = Studio.mailer_from_for_transport( ses_from: "My App " ) config.theme_primary = "#4BAF50" # Override default violet config.theme_logos = ["logo.svg"] # Smooth-load convention (default OFF). Renders the view-transition + # no-preview metas: Turbo page swaps materialize behind the current page and # present with a view transition, exactly one render per navigation. Fix any # multi-second pages BEFORE opting in — no-preview holds the old page until # the fresh response arrives. config.smooth_load = true # Nav spinner minimum display (default 2500). Smooth-load apps typically # drop to ~300; keep the high floor if multi-second ops ride the spinner. config.nav_spinner_min_ms = 300 end ``` ### Local log rotation (automatic — nothing to configure) The engine caps the host app's **development** log at 16 MB and its **test** log at 8 MB, keeping one rotated sibling each. There is nothing to install, run, or remember: it rides the gem, so every checkout and every worktree is born with it. It exists because Rails' own default is far too generous for a machine that carries many worktrees. `config.load_defaults "7.1"` sets `log_file_size` to **100 MB** for development *and* test, and each keeps a rotated sibling — up to ~400 MB of log per checkout. **Production is untouched.** The cap applies only where `Rails.env.local?`, so apps that hand their stream to STDOUT for the platform keep doing exactly that. A host that names its own `config.logger` is never overridden. To choose your own cap, or to opt out: ```ruby # config/application.rb (after `require "studio"`) or config/environments/development.rb Studio.local_log_max_bytes = 64.megabytes # your own cap Studio.local_log_max_bytes = false # opt out; Rails' 100 MB default returns ``` **This one setting cannot go in `config/initializers/studio.rb`.** It is read during boot — Rails builds the logger in a bootstrap initializer, long before `config/initializers` is loaded — so an initializer would be too late and would silently do nothing. Every *other* `Studio.*` setting belongs in the initializer as usual. Transactional mail transport is shared through `Studio::MailTransport`: ```ruby # config/initializers/studio_mail_transport.rb Studio::MailTransport.configure! ``` It selects SES SMTP when `MAIL_TRANSPORT=ses` and SES SMTP credentials are present, otherwise falls back to Resend when `RESEND_API_KEY` is present. ## Routes In the consuming app's `config/routes.rb`: ```ruby Rails.application.routes.draw do Studio.routes(self) # ... app routes end ``` This draws the enabled auth routes (`/login`, `/signup`, `/logout`, `POST /magic_link` to request a link, `GET`/`POST /l/:token` for the link itself, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`. Set `Studio.draw_geo_routes = true` to add the geo manager (`/admin/geo`) and its public probe (`/geo/check`) — off by default because turf-monster owns those helper names until its adoption lands. Magic-link emails point at the inert `GET /l/:token` confirmation page; the single-use token is burned only by the CSRF-protected `POST` to `link_consume_path`. Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpoint (`GET /session/state`) — off by default because it inherits the host's filters, which an app should check first ([`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md)). **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`. In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email. ## Non-Production Banners Consumer layouts can render the shared environment banner inside their sticky header: ```erb <%= render "studio/banners/environment", devnet: false %> ``` The environment banner includes: - a Dev Mode toggle button backed by `Alpine.store("devMode")` - an Email status button that links to `/_studio/local_emails` - a send/capture signal plus SES/Resend/unknown connector icon Apps with admin Act As / impersonation state can render the matching banner with their own users and return route: ```erb <%= render "studio/banners/impersonation", impersonated_user: current_user, admin_user: true_user, stop_path: admin_stop_impersonating_path %> ``` The engine also provides an optional `Studio::Impersonation` concern for the session convention: ```ruby class ApplicationController < ActionController::Base include Studio::ErrorHandling include Studio::Impersonation end ``` The concern adds `true_user`, `impersonated_user`, `impersonating?`, `start_impersonation_session(target_user, actor:)`, and `clear_impersonation_session`. Consumer apps still own the authorization rule, audit log, enter/exit controller actions, and any app-specific safeguards such as binding session-token checks to `true_user` or disabling wallet-only privileges while impersonating. ## UI Primitives ### The "at" time stamp — `at_time_tag` Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a date only when the stamp is not today and the year only when it differs. A country flag trails the clock when the reader's timezone is outside the US, and inside the US there is no flag at all — it carries signal only because it is unusual. The relative phrase ("7 minutes ago") moves to the hover title. Render the re-stamper **once per page, near the end of the layout body**, then use the helper anywhere: ```erb <%# near the end of , once %> <%= render "studio/at_time_script" %> <%# anywhere %> <%= at_time_tag(release.shipped_at) %> <%= at_time_tag(task.created_at, prefix: nil) %> ``` **Near the END of the body matters.** The script's first pass runs synchronously as it parses, so rendering it in `head` finds zero stamps on that pass and leaves them until the next one. The server renders the app-timezone form as a no-JS fallback and never renders a flag — it cannot know where the reader is sitting, so only the reader's machine may assert one. A host that omits the script still gets working stamps, just frozen in the app's timezone. Specimen: `/admin/style` → Tricks → Time stamps. ### Smooth-load header pin — `.vt-pinned-header` When `Studio.smooth_load` is on, put `vt-pinned-header` on the app's sticky header: it gets its own named view-transition group, so page content transitions beneath a navbar that stays put (or smoothly morphs heights). **Exactly one element per page** — a duplicate `view-transition-name` makes the browser silently skip the whole transition, with no error and no animation. ```erb
``` Render `components/emoji_swap` inside a link or button with the `group` class to slide between two emoji on hover and keyboard focus. The CSS ships through `studio_theme_css_tag`, including a reduced-motion fade fallback. ```erb <%= link_to root_path, class: "group inline-flex items-center gap-2" do %> <%= render "components/emoji_swap", base: "📊", hover: "✨" %> Dashboard <% end %> ``` ### Modal host `studio/modals/_host.html.erb` is the single shared shell for every modal. It owns the backdrop, scroll lock, escape + click-outside dismissal, ARIA dialog role, mount/unmount animations, and bfcache/Turbo snapshot cleanup. Animation keyframes ship inline in the partial — consumers need no extra CSS. Render it once near the end of the layout ``, registering each modal in the block: ```erb <%= render "studio/modals/host" do %> <% end %> ``` #### Writing a modal's content partial — two rules the host imposes Both rules let the card render and then do less than it looks like it does. They differ in whether anything reaches the CONSOLE, which is the first thing to check when a registered partial misbehaves. **1. SINGLE ROOT — fails in total silence.** A content partial's outer `
` is the host's required root. Alpine's `