studio-engine 0.122.0 → 0.124.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +142 -0
  3. data/README.md +18 -3
  4. data/app/assets/tailwind/studio_engine/engine.css +210 -11
  5. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  6. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  7. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  8. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  9. data/app/controllers/studio/voice_takes_controller.rb +11 -0
  10. data/app/controllers/style_controller.rb +2 -5
  11. data/app/controllers/theme_settings_controller.rb +8 -8
  12. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  13. data/app/helpers/studio/site_footer_helper.rb +15 -2
  14. data/app/javascript/studio/agent_chat.js +42 -2
  15. data/app/javascript/studio/agent_chat_voice.js +318 -0
  16. data/app/javascript/studio/alpine_shims.js +1 -3
  17. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  18. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  19. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  20. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  21. data/app/javascript/studio/microphone_chip.js +1 -0
  22. data/app/javascript/studio/page_header.js +24 -0
  23. data/app/javascript/studio/page_transitions.js +181 -0
  24. data/app/javascript/studio/pinned_stack.js +12 -1
  25. data/app/javascript/studio/stimulus.js +9 -1
  26. data/app/javascript/studio/style_guide.js +2 -0
  27. data/app/javascript/studio/style_section_nav.js +16 -0
  28. data/app/javascript/studio/theme_editor.js +6 -106
  29. data/app/javascript/studio/voice_recorder.js +23 -2
  30. data/app/models/studio/user_session.rb +76 -0
  31. data/app/models/theme_setting.rb +3 -0
  32. data/app/views/components/_link_sidebar.html.erb +5 -2
  33. data/app/views/components/_user_nav.html.erb +5 -4
  34. data/app/views/layouts/_navbar.html.erb +7 -4
  35. data/app/views/layouts/studio/_head.html.erb +6 -0
  36. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  37. data/app/views/studio/_page_header.html.erb +65 -0
  38. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  39. data/app/views/studio/admin_models/show.html.erb +15 -12
  40. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  41. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  42. data/app/views/studio/agent_chat/_form.html.erb +4 -3
  43. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  44. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  45. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  46. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  47. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  48. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  49. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  50. data/app/views/studio/voice/_microphone_help.html.erb +6 -3
  51. data/app/views/style/_theme.html.erb +75 -12
  52. data/app/views/style/_tricks.html.erb +35 -0
  53. data/app/views/style/index.html.erb +21 -34
  54. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  55. data/docs/SITE_FOOTER.md +146 -2
  56. data/lib/studio/agent_chat.rb +83 -5
  57. data/lib/studio/marketing_kit.rb +304 -0
  58. data/lib/studio/site_footer.rb +65 -6
  59. data/lib/studio/ui_primitives.rb +4 -2
  60. data/lib/studio/version.rb +1 -1
  61. data/lib/studio.rb +146 -6
  62. metadata +21 -4
  63. data/app/views/studio/admin_models/index.html.erb +0 -31
  64. data/app/views/theme_settings/edit.html.erb +0 -304
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd0f77b33c7833a6cb5061bc17b11baad3db5ad3b2e6ad232b31cc363d124e49
4
- data.tar.gz: 942b24a25b2dbdb8d7e1af5a918c323718fcb2438eced8b46dd1593f1563d9a0
3
+ metadata.gz: b7f68b7fc8c9d951dfa0f8069db58d4d819a9e5c785a35c5319c00d1a7450fab
4
+ data.tar.gz: 3674ddc7b3c29a7f58e06ee8c928bf63223a52388a191f819a90919986355e79
5
5
  SHA512:
6
- metadata.gz: 2d81f612a81ddb2d5e9ccdf6c85d4e4cea1bd47043ba5e0c9732202f55e39489b44059646e8f42848530525c2c4c8f23ecd299afeb322b43adb3dd3bb513ff1a
7
- data.tar.gz: 3f6a3214f4a5a80cfc2c73453dd0429161f28879ba3bd41857160ab02c2f70bb633213d317c19d9026e786afb7d99c37ddc5e6e1da2793d8edf495fa976f216d
6
+ metadata.gz: 1b8755f0f24f02d33aaf1f91bac610325e988a5cf9633ec74b31227b14f9f8582293ff0bd225b38efdc62e468b433dabb66fa14590e785355073f70d339add32
7
+ data.tar.gz: '083f8e499dedaa8efb934df433a3cd99c196828d7625e77e0cd4cd3f5003d5ab57a26990eee8c031a803d4b80fb7777ce05133cb4fda5bd616a38b448a288dc6'
data/CHANGELOG.md CHANGED
@@ -4,6 +4,148 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.124.0 — 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **App-to-app page transitions: `Studio.page_transition_direction`** (`:left`, `:right`,
12
+ `:top`, `:bottom`, default nil). Each app moves its pages one way, and each page change
13
+ draws that direction's fade or unfold at random, at the Codrops demo's values. It is the
14
+ one switch: a set direction turns smooth-load on, metas and navbar pin included. The
15
+ navbar and banner stack hold still; eager images load before the swap, capped at 500ms;
16
+ a same-page visit, reduced motion and a browser without view transitions play nothing.
17
+ With nil, the HTML is unchanged except one more import map entry that nothing fetches.
18
+ New: `studio/page_transitions`, loaded by its own tag only with the setting on, and the
19
+ `data-vt-morph` marker for a named element (the `agent_chat` portrait and composer carry
20
+ it; a host marks its own when it sets a direction). No host changes here. The doc holds
21
+ a proposed assignment. [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
22
+ - **The marketing kit: every app has a public `/marketing-kit`, and its footer links
23
+ it, with no setup.** `Studio.routes` draws the page (`Studio.draw_marketing_kit_routes`,
24
+ default true; helper `studio_marketing_kit_path`) with no sign-in, and a host's own
25
+ route of that path wins from either side of `Studio.routes`. `Studio.marketing_kit`
26
+ (a Hash or a callable taking the view) declares `logos`, `palette`, `typefaces` and
27
+ `tagline`; unset, the logos derive from `Studio.navbar_wordmark`, the footer's lockup
28
+ and `Studio.theme_logos`, and the colours from what the app paints. Logos read "Download Light" and "Download Dark", the PNG when
29
+ there is one, else the SVG; a missing file shows no link and no broken image. One
30
+ click on a colour copies its hex and confirms (new: the lazy `copy-text` controller).
31
+ An app with no database can draw the page alone with `Studio.marketing_kit_routes(self)`.
32
+ - **The footer's Marketing Kit link**, in the brand block (`.ftr-kit`), on every app
33
+ with the page, signed in or out. An app that links the page in a column or on the
34
+ legal line gets no second link, and `marketing_kit: false` in the facts shows none.
35
+ Its columns and legal line are unchanged. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md),
36
+ *The marketing kit*.
37
+ - **`studio/_page_header`, and the triple stack as a standard.** A page title,
38
+ optional meta beside it and optional tabs on the right. At rest it has no
39
+ background and no border; once stuck under the navbar it takes the bar. It joins
40
+ the pinned stack (`data-pin="page"`), so a sticky table header sits under it:
41
+ navbar, page row, table header, with no overlap. `/admin/style`, `/admin/models`
42
+ and `/admin/surveys` use it.
43
+ [`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md) carries the rule and the locals. New:
44
+ `studio/page_header` and the lazy `page-header` controller.
45
+
46
+ ### Changed
47
+
48
+ - **A signed-out visitor's link sidebar has no "Links" title.** Signed-in and admin titles
49
+ are unchanged, and the panel keeps its accessible name.
50
+
51
+ ### Fixed
52
+
53
+ - **A tap no longer leaves the navbar's account group lit.** On a touch screen a tap
54
+ leaves `:hover` on what was tapped, so the gear, the name and the avatar (THE ACCOUNT
55
+ GROUP in `engine.css`) stayed lit after the sidebar closed. Every `:hover` of the
56
+ group now sits inside `@media (hover: hover)`; `:focus-visible` stays outside it, so
57
+ a keyboard still lights the group. A mouse sees no change.
58
+ - **The theme toggle draws left of the gear at every width, signed in or out.** The
59
+ signed-in desktop rail already did. The signed-out rail (`layouts/_navbar` and the
60
+ signed-out branch of `components/_user_nav`) and the phone row drew the gear first.
61
+ The navbar baseline (`test/fixtures/files/navbar_unset_baseline`) is regenerated for
62
+ this order: 14 of its 19 files move, and only the order of their lines changes. A
63
+ host that draws its own pair keeps its own order: the hub's
64
+ `layouts/_navbar_phone_account` still draws gear, then toggle.
65
+ - **A table's sticky header no longer sits on top of the page row on `/admin/models`.**
66
+ - **The open tab on `/admin/models` is scrolled into view at phone width.**
67
+ - **`studio/pinned_stack` places a header with no `data-pin` at its place in the
68
+ document.** It was appended, so it read as below every other layer, and a layer
69
+ under it composed a top of 0.
70
+
71
+ ## 0.123.0 — 2026-11-03
72
+
73
+ ### Added
74
+
75
+ - **Hold to talk in the shared agent chat.** `render "studio/agent_chat/chat", ..., voice: true`
76
+ puts the microphone chip and a hold-to-talk button in the composer's mic slot,
77
+ with the approved privacy line under it (`Studio::AgentChat::VOICE_PRIVACY`;
78
+ `voice_copy: { privacy:, recorder:, chip: }` overrides). Off by default, and
79
+ shown only where `Studio.voice_transcription` is on. Moved from the hub's
80
+ `chat/_voice` and `voice_chat.js` (new: `studio/agent_chat_voice`, the lazy
81
+ `agent-chat-voice` controller). Two fixes on the way: a new hold clears the last
82
+ take's line, and a take recorded during transcription waits its turn. A failed
83
+ send now adds the words after a typed draft instead of losing them.
84
+ [`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md) carries the adoption checklist.
85
+ - **`Studio.voice_chat_allowed`**, a callable taking the signed-in user (default:
86
+ anyone signed in). It decides who sees the mic, who may post to
87
+ `POST /voice/takes` (a JSON `403 {error, reason: "not_allowed"}` otherwise), and
88
+ whether the proxy forwards a message's `voice`. A signed-out visitor is never
89
+ allowed, whatever the hook says.
90
+ - **`studio:voice-recorder:hint`**, on the recorder: `{ kind, text }` for a line
91
+ while it records. A blank `copy.recording[kind]` now blanks that line.
92
+ - **`Studio.agent_chat_allowed`**, the chat's server-side gate: a callable taking
93
+ the user, nil when signed out (default: everyone, so a public chat is unchanged).
94
+ `Studio::AgentChatsController` answers a refused viewer a JSON `403 {error, reason:
95
+ "not_allowed"}` on both calls before any hub call, and the chat partial renders
96
+ nothing for them. The footer's Chat link follows it unless
97
+ `site_footer_chat_allowed` is set, and a user it refuses may not talk by voice.
98
+ - **The proxy carries `voice`.** `POST /agent_chat/messages` and
99
+ `Studio::AgentChat.post_message(voice:)` forward `{check, score, duration_ms}`
100
+ to the hub, and never a name: the hub names the speaker. A malformed `voice` is
101
+ a `422`. When the hub answers what it kept, the proxy answers `{turn, voice}`.
102
+ - **A public refresh hook on the chat.** Dispatch `studio:agent-chat:refresh` on
103
+ the chat's section, optionally with `{detail: {turn}}`: the turn shows at once
104
+ and the chat polls now; a poll in flight is followed by one more.
105
+ - **The footer's standard Chat link.** `Studio.site_footer_chat_path` (a path or a
106
+ callable taking the view), `site_footer_chat_label` (default "Chat") and
107
+ `site_footer_chat_allowed` (a callable taking the viewer, nil when signed out;
108
+ default `Studio.agent_chat_allowed`) add a plain link at the end of the Contact
109
+ column, or a Contact column if the facts have none. No path, no link.
110
+ [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md), *The Chat link*.
111
+ - **`Studio::UserSession` and `studio_user_sessions`.** One row per signed-in browser
112
+ session: user, admin, started, last seen. A new row after 30 idle minutes; at most one
113
+ write per 5 minutes, and no query between writes. No IP and no user agent. An app with
114
+ the migration records automatically.
115
+ - **A standard Sessions tab on the admin models page** when the table exists.
116
+
117
+ ### Changed
118
+
119
+ - **The admin models page is one table per tab.** The title and tabs share one sticky
120
+ row and the open tab is highlighted. `/admin/models` shows the first tab; a page's `h1`
121
+ is now always "Models". `studio/admin_models/index` and `PREVIEW_LIMIT` are gone.
122
+ - **`/admin/style` has one sticky header row.** The title and the section tabs
123
+ share it, and the tab highlight follows the section in view
124
+ (`studio/style_section_nav`). The intro paragraph is gone; the version sits at
125
+ the row's right.
126
+ - **`GET /admin/theme` redirects to `/admin/style#theme`.** The route and the
127
+ `admin_theme_path` helper stay, and so do the save endpoints. A form save now
128
+ lands on the style guide. The standalone page's logos, semantic tokens and type
129
+ moved to the Theme section; its button and component specimens were already in
130
+ Tricks.
131
+
132
+ ### Removed
133
+
134
+ - **`/admin/design_system` and `admin_design_system_path`.** The route only
135
+ redirected to `/admin/style`. No app links it.
136
+ - **`theme_settings/edit` and the `themeEditor` factory** (`window.themeEditor`).
137
+ `dsThemeEditor` is the one editor.
138
+
139
+ ### Fixed
140
+
141
+ - **The microphone help's Close never submits a surrounding form.** It is a plain
142
+ button now, not `<form method="dialog">`, which the parser drops inside a form.
143
+ - **A hold-to-talk recorder stops when a held key loses focus**, instead of recording
144
+ to the cap and sending a take on its own, and a right-click or control-click no
145
+ longer starts a take.
146
+ - **The emoji swap no longer clips a large glyph.** Its box is `max(1.25rem, 1.25em)` tall and
147
+ `max(1.5rem, 1.25em)` wide, so a 24px emoji fits.
148
+
7
149
  ## 0.122.0 — 2026-11-01
8
150
 
9
151
  ### Added
data/README.md CHANGED
@@ -41,9 +41,10 @@ resolved.
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
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).
44
+ - **App-to-app page transitions**: `config.page_transition_direction` (`:left`, `:right`, `:top`, `:bottom`) moves an app's pages one way on every page change, with the navbar and banners held still. It turns smooth-load on. See [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
44
45
  - **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).
45
46
  - **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).
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).
47
+ - **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; `config.site_footer_chat_path` adds a standard Chat link to the Contact column. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
47
48
  - **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
48
49
  - **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).
49
50
  - **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).
@@ -169,6 +170,7 @@ Studio.configure do |config|
169
170
  # multi-second pages BEFORE opting in — no-preview holds the old page until
170
171
  # the fresh response arrives.
171
172
  config.smooth_load = true
173
+ # config.page_transition_direction = :left # the app's page move; also turns smooth-load on
172
174
  # Nav spinner minimum display (default 2500). Smooth-load apps typically
173
175
  # drop to ~300; keep the high floor if multi-second ops ride the spinner.
174
176
  config.nav_spinner_min_ms = 300
@@ -249,7 +251,7 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
249
251
 
250
252
  **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`.
251
253
 
252
- 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 ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md)).
254
+ 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)).
253
255
 
254
256
  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.
255
257
 
@@ -285,6 +287,17 @@ actions; the engine supplies only the banner.
285
287
 
286
288
  ## UI Primitives
287
289
 
290
+ ### The page header — `studio/_page_header`
291
+
292
+ An admin page's title: a plain header at rest, a bar once stuck under the navbar,
293
+ in the pinned stack so a table's sticky header sits under it
294
+ ([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md)).
295
+
296
+ ```erb
297
+ <%= render "studio/page_header", title: "Models", meta: "v1.2.3", label: "Models",
298
+ tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %>
299
+ ```
300
+
288
301
  ### The "at" time stamp — `at_time_tag`
289
302
 
290
303
  Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
@@ -1159,7 +1172,9 @@ pointing at or focusing any of the gear, the name or the avatar lights all three
1159
1172
  (the primary colour, a turn of the gear, a primary glow on the avatar). The
1160
1173
  same holds for a host sidebar: a host that sets both `navbar_menu_trigger` and
1161
1174
  `navbar_account_sidebar` gets its gear and account lit as one menu. An account
1162
- link is never grouped (`engine.css`, *THE ACCOUNT GROUP*).
1175
+ link is never grouped (`engine.css`, *THE ACCOUNT GROUP*). Pointing needs a
1176
+ pointer: the hover half sits inside `@media (hover: hover)`, so a tap on a touch
1177
+ screen lights nothing, and a keyboard still does.
1163
1178
 
1164
1179
  ## Transactional emails
1165
1180
 
@@ -223,6 +223,93 @@ body.modal-open .studio-app-banner {
223
223
  line-height: 1.4;
224
224
  }
225
225
 
226
+ /* -- The page header (studio/_page_header, docs/PAGE_HEADER.md) ---------
227
+ A normal page header at rest and a bar once stuck. The border is there in
228
+ both states, in transparent at rest, so the row's height never changes and
229
+ the pinned stack has nothing to republish. The layout is owned here: the
230
+ engine's views depend on no responsive utility a host may not have emitted.
231
+ The fade is owned too, because .fade-edge-x is in the opt-in motion layer, and
232
+ it marks only an end that hides a tab (studio/controllers/page_header). */
233
+
234
+ @utility page-header {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 0.75rem;
238
+ padding-block: 0.5rem;
239
+ border-block: 1px solid transparent;
240
+ background-color: transparent;
241
+ transition: background-color 150ms ease, border-color 150ms ease;
242
+
243
+ &[data-stuck],
244
+ [data-studio-controller-failed~="page-header"] + & {
245
+ @apply bg-surface border-subtle;
246
+ }
247
+ }
248
+
249
+ @utility page-header-sentinel {
250
+ position: absolute;
251
+ width: 0;
252
+ height: 0;
253
+ }
254
+
255
+ @utility page-header-lead {
256
+ display: flex;
257
+ align-items: baseline;
258
+ gap: 0.5rem;
259
+ flex-shrink: 0;
260
+ min-width: 0;
261
+ }
262
+
263
+ @utility page-header-title {
264
+ @apply text-heading;
265
+ font-size: calc(var(--type-base, 16px) * 1.5);
266
+ font-weight: var(--type-display-weight, 800);
267
+ line-height: 1.25;
268
+ }
269
+
270
+ @utility page-header-meta {
271
+ white-space: nowrap;
272
+
273
+ @media (width < 40rem) {
274
+ display: none;
275
+ }
276
+ }
277
+
278
+ @utility page-header-tabs {
279
+ --page-header-fade-start: 0px;
280
+ --page-header-fade-end: 0px;
281
+ flex: 1 1 0%;
282
+ min-width: 0;
283
+ overflow-x: auto;
284
+ scrollbar-width: none;
285
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
286
+ mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
287
+
288
+ &[data-fade-start] {
289
+ --page-header-fade-start: 2rem;
290
+ }
291
+
292
+ &[data-fade-end] {
293
+ --page-header-fade-end: 2rem;
294
+ }
295
+ }
296
+
297
+ /* width: max-content, then an auto margin: the tabs float right while they fit,
298
+ and start at the left, all reachable, once they do not. justify-content would
299
+ push the overflow past the start, where no scroll can reach it. */
300
+ @utility page-header-track {
301
+ display: flex;
302
+ align-items: center;
303
+ gap: 0.5rem;
304
+ width: max-content;
305
+ margin-left: auto;
306
+ }
307
+
308
+ @utility page-header-actions {
309
+ flex-shrink: 0;
310
+ margin-left: auto;
311
+ }
312
+
226
313
  /* -- Scrims: tints and shades ----------------------------------------------
227
314
  A scrim is the wash laid over media (a photo, a video) so text reads on it.
228
315
  It comes in two TONES, and the tone is a design decision, not a colour pick:
@@ -565,6 +652,87 @@ body.modal-open .studio-app-banner {
565
652
  animation: none;
566
653
  }
567
654
 
655
+ /* -- App-to-app page transitions --------------------------------------------
656
+ Studio.page_transition_direction (studio/page_transitions, docs/PAGE_TRANSITIONS.md).
657
+ Every rule is gated on an attribute only that script sets, so an app without
658
+ the setting keeps the fade above. Attribute selectors outrank the bare
659
+ ::view-transition-*(root) rules, here and in a host. The effects are from the
660
+ Codrops Page Transitions demo, at its values; the keyframes were written fresh.
661
+
662
+ Black behind the pages gives the shading: a page at opacity 0.3 darkens as it
663
+ moves. */
664
+ html[data-page-transition]:not([data-page-transition="still"])::view-transition {
665
+ background: #000;
666
+ }
667
+
668
+ html[data-page-transition]::view-transition-old(root),
669
+ html[data-page-transition]::view-transition-new(root) {
670
+ mix-blend-mode: normal;
671
+ animation-duration: 700ms;
672
+ animation-timing-function: ease;
673
+ animation-fill-mode: both;
674
+ backface-visibility: hidden;
675
+ }
676
+
677
+ /* A same-page visit (a refresh, a poll) draws nothing. */
678
+ html[data-page-transition="still"]::view-transition-old(root),
679
+ html[data-page-transition="still"]::view-transition-new(root) {
680
+ animation: none;
681
+ }
682
+
683
+ /* The banner stack holds still with the navbar. The name goes on the one element
684
+ the script marks, since a duplicate name silently cancels the transition. It
685
+ lasts for the visit alone: a name at rest makes the stack a stacking context,
686
+ and its tooltip then paints under the navbar. No group animation either: a page
687
+ left scrolled has the stack above the viewport, and it would slide down. */
688
+ html[data-page-transition-direction][data-page-transition-visit] [data-vt-held] {
689
+ view-transition-name: studio-bar-stack;
690
+ }
691
+
692
+ ::view-transition-group(studio-bar-stack),
693
+ ::view-transition-old(studio-bar-stack),
694
+ ::view-transition-new(studio-bar-stack) {
695
+ animation: none;
696
+ }
697
+
698
+ /* A named element moves with the page, unless both pages share its name. The
699
+ script decides that before the old page's picture, and only then is the name let
700
+ through, so the morph stands. data-vt-morph carries the name it marks. */
701
+ html[data-page-transition-direction]:not([data-page-transition-morph]) [data-vt-morph] {
702
+ view-transition-name: none !important;
703
+ }
704
+
705
+ html[data-page-transition="fade-left"]::view-transition-old(root) { animation-name: pt-out-left; }
706
+ html[data-page-transition="fade-left"]::view-transition-new(root) { animation-name: pt-in-from-right; }
707
+ html[data-page-transition="fade-right"]::view-transition-old(root) { animation-name: pt-out-right; }
708
+ html[data-page-transition="fade-right"]::view-transition-new(root) { animation-name: pt-in-from-left; }
709
+ html[data-page-transition="fade-top"]::view-transition-old(root) { animation-name: pt-out-top; }
710
+ html[data-page-transition="fade-top"]::view-transition-new(root) { animation-name: pt-in-from-bottom; }
711
+ html[data-page-transition="fade-bottom"]::view-transition-old(root) { animation-name: pt-out-bottom; }
712
+ html[data-page-transition="fade-bottom"]::view-transition-new(root) { animation-name: pt-in-from-top; }
713
+
714
+ html[data-page-transition="unfold-right"]::view-transition-old(root) { animation-name: pt-out-left; }
715
+ html[data-page-transition="unfold-right"]::view-transition-new(root) { animation-name: pt-unfold-right; transform-origin: 0% 50%; }
716
+ html[data-page-transition="unfold-left"]::view-transition-old(root) { animation-name: pt-out-right; }
717
+ html[data-page-transition="unfold-left"]::view-transition-new(root) { animation-name: pt-unfold-left; transform-origin: 100% 50%; }
718
+ html[data-page-transition="unfold-bottom"]::view-transition-old(root) { animation-name: pt-out-top; }
719
+ html[data-page-transition="unfold-bottom"]::view-transition-new(root) { animation-name: pt-unfold-bottom; transform-origin: 50% 0%; }
720
+ html[data-page-transition="unfold-top"]::view-transition-old(root) { animation-name: pt-out-bottom; }
721
+ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-name: pt-unfold-top; transform-origin: 50% 100%; }
722
+
723
+ @keyframes pt-out-left { to { opacity: 0.3; transform: translateX(-100%); } }
724
+ @keyframes pt-out-right { to { opacity: 0.3; transform: translateX(100%); } }
725
+ @keyframes pt-out-top { to { opacity: 0.3; transform: translateY(-100%); } }
726
+ @keyframes pt-out-bottom { to { opacity: 0.3; transform: translateY(100%); } }
727
+ @keyframes pt-in-from-right { from { opacity: 0.3; transform: translateX(100%); } }
728
+ @keyframes pt-in-from-left { from { opacity: 0.3; transform: translateX(-100%); } }
729
+ @keyframes pt-in-from-bottom { from { opacity: 0.3; transform: translateY(100%); } }
730
+ @keyframes pt-in-from-top { from { opacity: 0.3; transform: translateY(-100%); } }
731
+ @keyframes pt-unfold-right { from { opacity: 0; transform: perspective(1200px) translateX(100%) rotateY(90deg); } }
732
+ @keyframes pt-unfold-left { from { opacity: 0; transform: perspective(1200px) translateX(-100%) rotateY(-90deg); } }
733
+ @keyframes pt-unfold-bottom { from { opacity: 0; transform: perspective(1200px) translateY(100%) rotateX(-90deg); } }
734
+ @keyframes pt-unfold-top { from { opacity: 0; transform: perspective(1200px) translateY(-100%) rotateX(90deg); } }
735
+
568
736
  /* Turbo's progress bar is the slow-load feedback under no-preview (the old
569
737
  page holds until the fresh response). Theme it so every app's bar matches
570
738
  its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
@@ -784,36 +952,67 @@ body.modal-open .studio-app-banner {
784
952
  saying they are its one menu, as Turf Monster's gear sidebar is.
785
953
  With Studio.navbar_name_in_rows the name is its own control in row 1
786
954
  ([data-nav-name-trigger]), and pointing at it lights the group too.
787
- An account link is never grouped. */
955
+ An account link is never grouped.
956
+
957
+ POINTING NEEDS A POINTER. Every :hover below sits inside
958
+ @media (hover: hover), and each :focus-visible rule stays outside it. On
959
+ a touch screen a tap leaves :hover on what was tapped until the next tap
960
+ lands elsewhere, so the group stayed lit after its sidebar closed. Tailwind's
961
+ hover: and group-hover: variants already carry this guard; these
962
+ hand-written selectors do not get it for free. */
788
963
  :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger] svg,
789
964
  :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-nav-account] > [aria-hidden] > * {
790
965
  transition: transform 200ms ease, box-shadow 200ms ease;
791
966
  }
792
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
793
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger],
794
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
795
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
967
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger],
968
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
969
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
796
970
  box-shadow:
797
971
  0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
798
972
  0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
799
973
  }
800
974
  @media (prefers-reduced-motion: no-preference) {
801
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
975
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
976
+ }
977
+ @media (hover: hover) {
978
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
979
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-link-sidebar-trigger],
980
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-name] { color: var(--color-primary); }
981
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-account] > [aria-hidden] > * {
982
+ box-shadow:
983
+ 0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
984
+ 0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
985
+ }
986
+ @media (prefers-reduced-motion: no-preference) {
987
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
988
+ }
802
989
  }
803
990
  :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls] svg,
804
991
  :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-account] > [aria-hidden] > * {
805
992
  transition: transform 200ms ease, box-shadow 200ms ease;
806
993
  }
807
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
808
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls],
809
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
810
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
994
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls],
995
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
996
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
811
997
  box-shadow:
812
998
  0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
813
999
  0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
814
1000
  }
815
1001
  @media (prefers-reduced-motion: no-preference) {
816
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
1002
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
1003
+ }
1004
+ @media (hover: hover) {
1005
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
1006
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-menu-trigger] [aria-controls],
1007
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-name] { color: var(--color-primary); }
1008
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-account] > [aria-hidden] > * {
1009
+ box-shadow:
1010
+ 0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
1011
+ 0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
1012
+ }
1013
+ @media (prefers-reduced-motion: no-preference) {
1014
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
1015
+ }
817
1016
  }
818
1017
  /* THE ACCOUNT ROW (components/_user_nav), from 768px. Every app whose
819
1018
  navbar renders the engine's user nav gets this, Turf Monster included:
@@ -2,7 +2,6 @@ module Studio
2
2
  module AdminModels
3
3
  extend ActiveSupport::Concern
4
4
 
5
- PREVIEW_LIMIT = 10
6
5
  PER_PAGE = 25
7
6
  TEAM_SORTS = {
8
7
  "team" => "LOWER(teams.name)",
@@ -10,27 +9,22 @@ module Studio
10
9
  "league" => "LOWER(COALESCE(teams.league, ''))"
11
10
  }.freeze
12
11
  SHARED_TABLE_KEYS = %w[teams arenas].freeze
12
+ SESSIONS_CONFIG = { label: "Sessions", description: "Signed-in sessions, most recent first" }.freeze
13
13
 
14
14
  included do
15
15
  before_action :set_model_config, only: :show
16
16
  helper Studio::AdminModelsTableHelper
17
- helper_method :team_sort_url, :team_sort_indicator, :team_sport_emoji,
17
+ helper_method :admin_model_configs, :team_sort_url, :team_sort_indicator, :team_sport_emoji,
18
18
  :team_record_json, :admin_models_table_partial
19
19
  end
20
20
 
21
+ # The first tab. Each model has its own tab and page (#show); this page is that
22
+ # first one, so /admin/models always shows a table.
21
23
  def index
22
- @sections = admin_model_configs.map do |key, config|
23
- scope = admin_model_scope_for(key)
24
- {
25
- key: key,
26
- label: config.fetch(:label),
27
- description: config.fetch(:description),
28
- count: scope.count,
29
- records: scope.limit(PREVIEW_LIMIT)
30
- }
31
- end
32
-
33
- render "studio/admin_models/index"
24
+ @key = admin_model_configs.keys.first.to_s
25
+ @config = admin_model_configs.fetch(@key)
26
+ @scope = admin_model_scope(@key)
27
+ show
34
28
  end
35
29
 
36
30
  def show
@@ -47,11 +41,23 @@ module Studio
47
41
  def set_model_config
48
42
  @key = params[:key].to_s
49
43
  @config = admin_model_configs.fetch(@key) { raise ActiveRecord::RecordNotFound }
50
- @scope = admin_model_scope_for(@key)
44
+ @scope = admin_model_scope(@key)
51
45
  end
52
46
 
47
+ # The host's models, with the engine's standard ones after its Users tab. A host
48
+ # adds a model by adding it to MODELS and answering it in #admin_model_scope_for.
53
49
  def admin_model_configs
54
- self.class::MODELS
50
+ configs = self.class::MODELS.to_a
51
+ return configs.to_h unless Studio::UserSession.available?
52
+
53
+ at = (configs.index { |key, _| key.to_s == "users" } || -1) + 1
54
+ configs.insert(at, ["sessions", SESSIONS_CONFIG]).to_h
55
+ end
56
+
57
+ def admin_model_scope(key)
58
+ return Studio::UserSession.includes(:user).recent if key.to_s == "sessions"
59
+
60
+ admin_model_scope_for(key)
55
61
  end
56
62
 
57
63
  def admin_model_scope_for(_key)
@@ -107,6 +113,8 @@ module Studio
107
113
  end
108
114
 
109
115
  def admin_models_table_partial(key)
116
+ return "studio/admin_models/sessions_table" if key.to_s == "sessions"
117
+
110
118
  shared_table_keys = if self.class.const_defined?(:SHARED_TABLE_KEYS, false)
111
119
  self.class::SHARED_TABLE_KEYS
112
120
  else
@@ -21,6 +21,7 @@ module Studio
21
21
  rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
22
22
 
23
23
  before_action :require_authentication
24
+ after_action :track_user_session
24
25
 
25
26
  helper_method :current_user, :logged_in?, :sso_user_available?, :sso_display_name, :sso_source_app, :sso_hub_logo, :admin?,
26
27
  :onchain_session?, :wallet_context, :client_session_payload
@@ -28,6 +29,18 @@ module Studio
28
29
 
29
30
  private
30
31
 
32
+ # The signed-in person, not whoever an admin is impersonating. A signed-out
33
+ # request stops at the cookie: it runs no query it would not otherwise run. A
34
+ # request must not fail because its bookkeeping did.
35
+ def track_user_session
36
+ return unless session[Studio.session_key]
37
+
38
+ user = respond_to?(:true_user, true) ? true_user : current_user
39
+ Studio::UserSession.track(session, user) if user
40
+ rescue StandardError => e
41
+ Rails.logger&.warn("[user_session] #{e.class}: #{e.message}")
42
+ end
43
+
31
44
  def current_user
32
45
  return @current_user if defined?(@current_user)
33
46
  @current_user = User.find_by(id: session[Studio.session_key])