studio-engine 0.130.0 → 0.132.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +169 -0
  3. data/README.md +52 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
  5. data/app/assets/tailwind/studio_engine/engine.css +604 -2
  6. data/app/assets/tailwind/studio_engine/theme.css +4 -0
  7. data/app/controllers/concerns/studio/error_handling.rb +53 -15
  8. data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
  9. data/app/controllers/magic_links_controller.rb +25 -2
  10. data/app/controllers/registrations_controller.rb +58 -22
  11. data/app/controllers/style_controller.rb +6 -0
  12. data/app/helpers/studio/agent_chat_helper.rb +14 -1
  13. data/app/helpers/studio/crumbs_helper.rb +34 -0
  14. data/app/helpers/studio/loader_helper.rb +79 -2
  15. data/app/javascript/studio/agent_chat.js +294 -6
  16. data/app/javascript/studio/carousel.js +107 -0
  17. data/app/javascript/studio/controllers/carousel_controller.js +161 -0
  18. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  19. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  20. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  21. data/app/javascript/studio/infinite_scroll.js +142 -0
  22. data/app/javascript/studio/loader.js +6 -1
  23. data/app/javascript/studio/page_transitions.js +65 -8
  24. data/app/javascript/studio/pull_to_refresh.js +147 -0
  25. data/app/javascript/studio/stimulus.js +12 -1
  26. data/app/javascript/studio/talk_to_agent.js +116 -0
  27. data/app/views/components/_geo_signpost.html.erb +2 -2
  28. data/app/views/components/_link_sidebar.html.erb +2 -2
  29. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  30. data/app/views/components/_sidebar_panel.html.erb +1 -1
  31. data/app/views/error_logs/index.html.erb +2 -8
  32. data/app/views/studio/_carousel.html.erb +112 -0
  33. data/app/views/studio/_page_header.html.erb +52 -11
  34. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  35. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  36. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  37. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  38. data/app/views/studio/loader/_loader.html.erb +20 -3
  39. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  40. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  41. data/app/views/style/_loaders.html.erb +118 -0
  42. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  43. data/app/views/style/_modals.html.erb +12 -0
  44. data/app/views/style/_specimen.html.erb +17 -10
  45. data/app/views/style/_tricks.html.erb +64 -2
  46. data/app/views/style/index.html.erb +14 -8
  47. data/app/views/style/loaders_feed.html.erb +7 -0
  48. data/lib/studio/crumbs.rb +150 -0
  49. data/lib/studio/loader.rb +30 -2
  50. data/lib/studio/version.rb +1 -1
  51. data/lib/studio.rb +3 -0
  52. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00ce81b9c4f82a619f86521fd4495d07e8a2a81f9f1eb5f15963e26f0cb038aa
4
- data.tar.gz: ed8a7169b6322c577ca796c974f4561bd3c4a2e29fa7da917cd28aec84f401b5
3
+ metadata.gz: dd2c739840540a736a0ad347abe13b22047ff20070c8e336df86172c6518d848
4
+ data.tar.gz: fced70c6c90490c2d74732bdae8270b25f0e7717a87623daf734984c6e4fb101
5
5
  SHA512:
6
- metadata.gz: 4f0f640580ed2f77fdc6093e9a90f2edfc12bf2ba6925fc8ad6b24cd5587668c84d465090be866dcf19c5f536cb38ccbff123f078a52dbee52f8b2f7d73d302d
7
- data.tar.gz: c02e3417b8f8eb1bb4b6b38706ddf79561959b61cacb7317a9465825c1724097c3b77fc5c4cf131f0011dff852b0c430c90f660277fd0067100a611cec10ec55
6
+ metadata.gz: fb8cc964841b4eb76cdb6a64a69cf2394f18d7d87863585c1159c2afc12305e5268acb8c508f7922744669793be9364135e79898415a1d6444c57c1e64dbd815
7
+ data.tar.gz: 3ad57bddb03aaf79c98c1f6f38ac5e92d743f59ce7701e502c092123b8731f0ac1c1d32a209310684662c8c81c6f2a2b44cbdb0a45c29d9265013aaa782ef607
data/CHANGELOG.md CHANGED
@@ -4,6 +4,175 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.132.0 — 2026-11-06
8
+
9
+ ## 0.131.0 — 2026-11-05
10
+
11
+ ### Added
12
+
13
+ - **A carousel opens where you tell it: `start:`, and a spotlight opens on its middle
14
+ slide.** Alex McRitchie saw an empty left half on a spotlight's first slide. `start:`
15
+ takes an index or `:middle`; it defaults to `:middle` for `style: :spotlight` and to 0
16
+ for a standard row, so a standard row is unchanged. The slide is the browser's
17
+ `scroll-initial-target`, so Chromium opens there before the first paint; elsewhere the
18
+ controller jumps there on connect, with no glide and an empty status line. The dots,
19
+ `data-current` and both arrows' `aria-disabled` are right from the server. A
20
+ `#fragment` inside the row wins over the start (task `carousel-start-and-button-glass`).
21
+ - **`.studio-border-glow--clear`.** The border glow on a clear button: no fill, only the
22
+ moving rainbow ring and its halo, with the heading ink as the label (16:1 light, 18:1 dark,
23
+ measured). `<button class="btn studio-border-glow studio-border-glow--clear">`. Under
24
+ reduced motion the ring and halo of every `.studio-border-glow` stand still.
25
+ - **The loader family: the eight ways a page says wait, in one place.** From the
26
+ reel Alex McRitchie shared (task `engine-loader-family`). `studio_loader` takes
27
+ three plain styles that draw no icon: `style: :spinner`, `style: :dots` and
28
+ `style: :bar` (a progressbar; `percent:` is its value, and with none it sweeps).
29
+ They wrap the canonical `.spinner`, `.loading-dots` and `.progress-meter` rules,
30
+ so there is one set of rules; an app's own `config.loader` still refuses them.
31
+ New: `studio_skeleton` (`:lines`, `:avatar`, `:card`, `:media`; aria-busy, shapes
32
+ hidden from assistive tech, a shimmer that stops under reduced motion),
33
+ `studio_infinite_scroll` (the next page loads in a Turbo frame as the list's end
34
+ nears, "Load more" stays visible, and a polite status line counts what came) and
35
+ `studio_pull_to_refresh` (phones with touch only). `/admin/style` has a Loaders
36
+ section with all eight, and a live infinite scroll from an admin-only demo feed
37
+ (`/admin/style/loaders_feed`). Busy buttons can show their busy words with
38
+ `data-loader-busy`. Docs: `docs/LOADER.md`, "The family".
39
+ - **Agent replies show a small, safe Markdown subset.** In `studio/agent_chat`, an agent
40
+ turn's `**bold**`, `*italics*`, `` `code` ``, http(s) links, `- ` and `1. ` lists and
41
+ line breaks render as such, and a leading `> ` is dropped. The nodes are built with
42
+ `createElement` and text nodes, never HTML; any other markup, and any link that is not
43
+ http or https, shows as its own characters. A visitor's words stay plain text. Detail:
44
+ `docs/AGENT_CHAT.md`, "The page's script".
45
+ - **A spotlight style for the carousel: `studio/_carousel`, `style: :spotlight`.** The
46
+ same carousel, arrows, dots and keys, but each slide snaps to the centre. The centred
47
+ slide is full size and colour, with an optional `caption:` line under it (a callable on
48
+ the item); its neighbours are smaller (0.75), faded and grey, and the next ones peek at
49
+ the edges. An arrow ("Previous slide", "Next slide") or key moves one slide, and every
50
+ slide gets a dot. It opens on its middle slide (`start:`, below). Where the browser
51
+ has scroll-driven animations (`animation-timeline: view()`), each slide's own view
52
+ timeline drives the look, so it follows the finger; elsewhere the controller marks the
53
+ centred slide `data-current` and CSS transitions it. Reduced motion scales and greys
54
+ nothing: the neighbours are only dimmed. An unknown `style:` raises. Specimen on
55
+ `/admin/style`, Tricks.
56
+ - **Talk to an agent in a modal: `studio/agent_chat/_talk_modal`.** Render it once on a page,
57
+ then mark any link with `studio_talk_to_agent_link(label, href:, soul:, topic:, opener:)`. A
58
+ click opens the shared agent chat in a card on the page-scoped modal host and sends the
59
+ opener as the visitor's first message once the thread loads, so the agent is already
60
+ answering while the card opens. A new answer types itself in. It is the same chat and the
61
+ same hub thread as `studio/agent_chat/chat`, not a second one. The opener goes once per soul
62
+ and topic per tab. A modified click, a page with no modal, or a viewer
63
+ `agent_chat_allowed` refuses follows the link's `href`. The chat takes two new section
64
+ attributes, `data-agent-chat-opener` (with `-opener-key`) and `data-agent-chat-reveal`, and
65
+ the partial takes `compact: true`. Detail: `docs/AGENT_CHAT.md`, "Talk to an agent in a
66
+ modal".
67
+ - **A crumb trail on the page header, from one parent map.** `studio/_page_header` takes
68
+ `crumbs:` (the page's ancestors, root first) and `crumb_label:`. The trail sits above the
69
+ title: muted ancestor links, the page itself bold, a chevron between. Below 40rem it gives
70
+ way to one "‹ Parent" link. An app names each parent page once in `Studio.crumbs.draw`
71
+ (`page :deployments, "Deployments", :deployments_path, parent: :admin`), and a view calls
72
+ `studio_crumbs(:key)` or `studio_crumbs(parent: :key)`. The row also takes `links:`, the
73
+ page's other links on a line at its foot that sticks with it, and renders `actions_html`
74
+ after the tabs when it has both. Detail: `docs/PAGE_HEADER.md`.
75
+ - **The gliding pill.** The page header's open tab is one pill, anchored by CSS anchor
76
+ positioning to the tab with `aria-current`, so it glides to the next tab when the scroll spy
77
+ or a click moves `aria-current`. No script measures it. Reduced motion jumps. A browser
78
+ without anchor positioning keeps each tab's own fill.
79
+
80
+ - **A carousel standard for the apps: `studio/_carousel`.** Render it as a layout, with
81
+ `label:` and `items:`, and the block draws each slide, so a slide is any block content.
82
+ The track is a native scroll-snap row: a swipe, a trackpad and a focused link move it with
83
+ no script. A wide row shows `per_view:` slides (default 3), a medium row up to two and a
84
+ phone one, each with a sliver of the next; the widths come from container queries on the
85
+ region, not the window. The lazy `carousel` controller adds the edge fade, arrows that
86
+ set `aria-disabled` at an end, dots that hide past the last stop, the arrow keys with Home
87
+ and End, and a polite status line after a press. It follows the APG carousel pattern:
88
+ a labelled region with `aria-roledescription="carousel"`, and each slide a labelled
89
+ group (`slide_label:` names it from its item). It honours `prefers-reduced-motion`, and
90
+ its arrows take the card fill, so they are glass under the glass surfaces. Until the
91
+ controller connects, the controls stay hidden and the row keeps a thin scrollbar. New
92
+ classes: `carousel`, `carousel-track`, `carousel-slide`, `carousel-controls`,
93
+ `carousel-dots`, `carousel-dot`, `carousel-arrows` and `carousel-button`. `/admin/style`
94
+ shows it under Tricks.
95
+
96
+ ### Changed
97
+
98
+ - **A dark, full-size lit button's fill drops from 0.85 to 0.65 alpha** (`--lit-fill-alpha`),
99
+ Alex McRitchie's "even more transparent". White label on the primary, measured from painted
100
+ pixels at the label's median: a dark page 3.95 to 5.62, the lab photo 3.16 to 3.29, a mid-grey
101
+ backdrop (#808080) 3.25 to 3.42, a lighter one (#a0a0a0) 3.06 to 2.98. Light pages stay solid
102
+ (3.10).
103
+ - **Back plays the app's page move the other way.** Browser Back (a Turbo restore that Turbo
104
+ marks `back`, or a full load that traverses to an earlier entry) and a click on a link with
105
+ `data-page-transition-back` (every ancestor crumb and the phone's parent link) play the
106
+ reverse direction in the same style: a `:left` slide goes back as `slide-right`, an
107
+ `:unfold` with `:right` as `unfold-right`. Forward keeps the app's own direction. Detail:
108
+ `docs/PAGE_TRANSITIONS.md`, "Back".
109
+ - **The page header's title is the type scale's h2 from 40rem** (30px, weight 800), Alex
110
+ McRitchie's "H2 header"; a phone keeps 1.5x the base. It is still the page's one `h1`.
111
+
112
+ ### Fixed
113
+
114
+ - **A standard carousel's `caption:` is a styled line** (small body text under each slide),
115
+ not bare text; the partial's docs and the README say which style shows which captions.
116
+ - **Review follow-ups on the sidebar, the carousel and the page header** (/tasks/engine-ui-polish-follow-ups).
117
+ The link sidebar's focus ring (its rows, close button, Log out, trigger and geo rows) is
118
+ `ring-primary-ink`, a new ring colour from the theme's primary ink: measured 5.4 to 8.9:1
119
+ beside every surface in dark, light and glass on five palettes, from 1.3 to 2.0:1 for
120
+ `ring-primary/40`. A carousel arrow or key that goes nowhere scrolls and announces nothing;
121
+ keys inside a slider, listbox, combobox, tab list, other arrow-key widget or nested carousel
122
+ stay theirs; and the focusable track is a group named "<label> slides". The page header row
123
+ is now always a `<div>`: with tabs, the tabs are their own `<nav>` named by `label`, beside
124
+ the crumb and links navs, so no landmark nests in another (a host test that found the row by
125
+ `nav[aria-label=…]` now finds the tabs). The `/admin/style` specimen adds no landmark.
126
+ The current link under the title is bold as well as coloured, and the pill's track sets
127
+ `anchor-scope`. `studio_crumbs` with a key the map lacks still raises in development and
128
+ test; elsewhere it logs to ErrorLog and draws no trail, not a 500. The dead redirect guard
129
+ in `studio/page_transitions` is gone.
130
+ - **`/admin/style`: Tricks is second, and nothing in the header is cut off.** The sections and
131
+ their pills run Theme, Tricks, Loaders, Modals, Tasks, Components (a host's own modals still
132
+ follow Modals). The tab scroller gives its pills 4px of room on every side, so their shadow, lit
133
+ edge and focus ring show, and the last pill's right edge too; phones still scroll sideways.
134
+ Each specimen card has one Copy (the snippet's, or the class name's when there is no
135
+ snippet), and the code wraps clear of it.
136
+ - **The talk modal's opener is never dropped.** A visitor who sent a message before the
137
+ thread first loaded lost the opener: `studio/agent_chat` marked it sent, then the
138
+ post returned early because the visitor's send was in flight. Now a send made
139
+ before the load posts the opener first, then the visitor's words; a send made while
140
+ the opener is in flight waits for it; and the tab remembers the opener only as its
141
+ POST starts, forgetting it if the POST fails. It is never sent twice.
142
+ - **`e2e/talk_to_agent.spec.js`'s modified-click spec no longer flakes.** It waited for
143
+ the new tab a Ctrl+click opens, which on CI's headless Linux Chromium stayed at
144
+ `about:blank` (it read "blank", and a wait for its URL timed out), so it measured the
145
+ browser rather than the modal. It now asserts what the modal owes a modified click:
146
+ the click is not prevented (the browser follows the `href`) and no card opens.
147
+ - **A signup that cannot send its email no longer fails silently.** `RegistrationsController#create`
148
+ ended in a `rescue StandardError` that logged nothing and rendered `registrations/new`. On a
149
+ passwordless app `@user` was never set, so a failed link mint or mail raised `NoMethodError` on
150
+ nil in that view, and no ErrorLog row named the first failure. Now a passwordless failure is
151
+ logged through `rescue_and_log` and redirects to `/signup` with one friendly alert for every
152
+ address, so it reveals nothing about which emails have accounts. The account is still created
153
+ only when the link is used. A password signup re-renders the form on a validation error and
154
+ writes no ErrorLog row. A POST with no `user` params gets the form too, not a
155
+ `ParameterMissing`. Any other raise is logged once and goes to the catch-all. The rescue no
156
+ longer names the unsaved user as its target: ErrorLog's `belongs_to` autosaved it, which ran the
157
+ failing save again inside the logger. That second raise skipped the logged mark, so the
158
+ catch-all wrote the same failure twice.
159
+ - **`rescue_and_log` writes one ErrorLog row per failure, also for an unsaved target.** It
160
+ linked `target:` with ErrorLog's `belongs_to`, which saves a new record. With an unsaved
161
+ record, the logger ran the failing save a second time. That raise skipped the logged mark,
162
+ so the catch-all wrote the same failure again, and a save that could pass created the
163
+ record. Now an unsaved target or parent is named on the row by its slug (else
164
+ `unsaved User`) and not linked. This covers the SSO, wallet and link sign-ups and the theme save. When the
165
+ ErrorLog write itself fails, in `rescue_and_log` or in the catch-all, `Rails.logger.error`
166
+ records both errors. `rescue_and_log` still raises the original error, and the catch-all
167
+ still gives its friendly reply. Before, the logger's error replaced both.
168
+ - **`POST /magic_link` answers a failed mint or mail the same way for every address.** The
169
+ raise went to the catch-all: "Something went wrong" on the home page, or a JSON 500. Now
170
+ the failure is logged once and the visitor goes back to `/login` with one alert. JSON gets
171
+ `503` with `{ success: false, error: "delivery_failed", message: … }`: the input was
172
+ fine and a retry can work, so it is not a 4xx. The body does not carry the internal
173
+ message, and the action never looks an account up, so it reveals nothing about which
174
+ emails have accounts. `Studio::MagicLinkIssuing::DELIVERY_FAILED` holds the words.
175
+
7
176
  ## 0.130.0 — 2026-11-05
8
177
 
9
178
  ### Added
data/README.md CHANGED
@@ -31,7 +31,7 @@ resolved.
31
31
  ## What It Provides
32
32
 
33
33
  - **Authentication**: Passwordless magic-link auth, optional password auth, Google OAuth via OmniAuth (ships ready: strategy, default `User.from_omniauth` and a setup modal until the OAuth client is set; docs/GOOGLE_AUTH_SETUP.md), Solana wallet sign-in, and optional one-way SSO patterns
34
- - **Error handling**: `Studio::ErrorHandling` concern with `rescue_and_log`, `ErrorLog` model with `capture!`, error log viewer at `/error_logs`
34
+ - **Error handling**: `Studio::ErrorHandling` concern with `rescue_and_log`, `ErrorLog` model with `capture!`, error log viewer at `/error_logs`. `rescue_and_log` writes one row per failure: an unsaved `target:` or `parent:` is named on the row by its slug (else `unsaved User`), never linked, so the logger never saves it. If the row itself cannot be written, the original error still raises and `Rails.logger.error` records both.
35
35
  - **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
36
36
  - **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
37
37
  - **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
@@ -184,7 +184,9 @@ form only when `:password` is enabled and the User has `authenticate`
184
184
  sign-in-link form when `:magic_link` is enabled, and the Google button only with
185
185
  `:google`. A passwordless signup stores nothing from the form: it mails a link,
186
186
  and using the link creates the account. So `registration_params`' `:name` shows a
187
- Name field only on a password app.
187
+ Name field only on a password app. If the link cannot be minted or mailed, the
188
+ failure is logged to ErrorLog and the visitor goes back to `/signup` with one
189
+ alert, the same for every address.
188
190
 
189
191
  `POST /login`, the password exchange, is drawn only when `auth_methods` includes
190
192
  `:password`. A passwordless app answers it with 404 for every address, so it
@@ -242,7 +244,7 @@ Rails.application.routes.draw do
242
244
  end
243
245
  ```
244
246
 
245
- 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`.
247
+ 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. If `POST /magic_link` cannot mint or mail the link, it logs the failure and answers the same for every address: the login page with one alert, or a `503` with `{ success: false, error: "delivery_failed", message: … }` for JSON. 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`.
246
248
 
247
249
  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)).
248
250
 
@@ -252,7 +254,7 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
252
254
 
253
255
  **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`.
254
256
 
255
- 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)).
257
+ 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").
256
258
 
257
259
  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.
258
260
 
@@ -290,10 +292,19 @@ actions; the engine supplies only the banner.
290
292
 
291
293
  ### The page header — `studio/_page_header`
292
294
 
293
- An admin page's title: a plain header at rest, a bar once stuck under the navbar,
294
- in the pinned stack so a table's sticky header sits under it
295
+ An admin page's title: a crumb trail from the app's one parent map (`Studio.crumbs`,
296
+ `studio_crumbs`) above an h2-size title, tabs with a gliding pill and one action on the right,
297
+ and the page's other links on a line under it. A plain header at rest, a bar once stuck under
298
+ the navbar, in the pinned stack so a table's sticky header sits under it
295
299
  ([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md)).
296
300
 
301
+ ```erb
302
+ <%= render "studio/page_header", title: "Deployments", crumbs: studio_crumbs(:deployments),
303
+ label: "Views", tabs: [{ label: "Board", href: deployments_path, current: "page" }],
304
+ actions_html: link_to("New Task", new_task_path, class: "btn btn-sm btn-primary"),
305
+ links: [{ label: "Epics", href: epics_path }] %>
306
+ ```
307
+
297
308
  ### The section header — `studio/_section_header`
298
309
 
299
310
  A section's title that sticks under the page row until the next section's takes its place,
@@ -304,6 +315,41 @@ with a bar once stuck ([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md#the-section-h
304
315
  tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %>
305
316
  ```
306
317
 
318
+ ### The carousel — `studio/_carousel`
319
+
320
+ The apps' standard for a set too long to grid: a swipeable scroll-snap row with
321
+ arrows, dots and arrow keys, a peek of the next slide, and an edge fade. Render it
322
+ as a layout; the block draws each slide. `per_view:` is the wide row's count
323
+ (default 3). `/admin/style` shows it under Tricks.
324
+
325
+ ```erb
326
+ <%= render layout: "studio/carousel", locals: { label: "Portfolio", items: @apps, per_view: 3 } do |app| %>
327
+ <%= render "apps/card", app: app %>
328
+ <% end %>
329
+ ```
330
+
331
+ `caption:` (a callable on the item) writes a small line under each slide.
332
+
333
+ `style: :spotlight` snaps each slide to the centre: the centred slide is full size and
334
+ colour, with its caption under it (the spotlight shows the centred slide's only), and its
335
+ neighbours are smaller, faded and grey. The arrows ("Previous slide", "Next slide") and
336
+ keys move one slide. Where the browser has scroll-driven animations the look follows the
337
+ scroll; elsewhere the controller marks the centred slide (`data-current`) and CSS
338
+ transitions it. Reduced motion only dims.
339
+
340
+ `start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight opens
341
+ on its middle slide by default, so neither half of the row is empty at load; a standard
342
+ row opens on its first. The browser sets it before the first paint where it has
343
+ `scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
344
+ glide and nothing announced. A `#fragment` link to an element inside the row wins.
345
+
346
+ ```erb
347
+ <%= render layout: "studio/carousel",
348
+ locals: { label: "Team", items: @people, style: :spotlight, caption: ->(person) { person.title } } do |person| %>
349
+ <%= render "people/card", person: person %>
350
+ <% end %>
351
+ ```
352
+
307
353
  ### The "at" time stamp — `at_time_tag`
308
354
 
309
355
  Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
@@ -33,6 +33,9 @@
33
33
  Loader:
34
34
  .studio-loader the app's icon as a branded loader (Loader Lab picks)
35
35
  .studio-loader-overlay the page-load overlay around it (docs/LOADER.md)
36
+ .studio-skeleton the content's shape while it loads (loader family)
37
+ .studio-infinite-scroll the next page near the end (loader family)
38
+ .studio-pull-refresh pull down to reload, phones only (loader family)
36
39
 
37
40
  Leveling:
38
41
  .level-badge(-1..-10) progressive level pill ladder (ported, TM)
@@ -2482,10 +2485,55 @@ body.modal-open { overflow: visible; }
2482
2485
  animation-delay: calc(var(--studio-loader-i, 0) * 0.6s - var(--studio-loader-sparks, 5) * 0.6s);
2483
2486
  }
2484
2487
 
2488
+ /* ---- The plain styles: no icon, only the theme's colours ----------------
2489
+ spinner, dots and bar wrap the canonical .spinner, .loading-dots and
2490
+ .progress-meter rules (sections 2, 3 and 7 above) and only set their knobs,
2491
+ so the family and those classes stay one set of rules. */
2492
+ /* Drawn on the page like ink, so in the theme's contrast-checked primary
2493
+ ink (theme_resolver), not the raw primary, which can sink into a dark
2494
+ page. A mono loader keeps currentColor. */
2495
+ .studio-loader:is([data-loader-style="spinner"], [data-loader-style="dots"], [data-loader-style="bar"]):not([data-loader-mono]) {
2496
+ --studio-loader-color: var(--color-primary-ink, var(--color-primary));
2497
+ }
2498
+
2499
+ /* :where() keeps the family's selectors from ending in the bare spinner class: a host
2500
+ counts that one rule to prove the motion layer is imported once
2501
+ (turf-monster test/lib/tailwind_css_first_test.rb). */
2502
+ .studio-loader[data-loader-style="spinner"] :where(.spinner) {
2503
+ --spinner-size: var(--studio-loader-size);
2504
+ --spinner-thickness: max(2px, calc(var(--studio-loader-size) * 0.1));
2505
+ --spinner-color: var(--studio-loader-color);
2506
+ --spinner-track: color-mix(in srgb, var(--studio-loader-color) 22%, transparent);
2507
+ }
2508
+
2509
+ .studio-loader[data-loader-style="dots"] { height: calc(var(--studio-loader-size) * 0.5); }
2510
+ .studio-loader[data-loader-style="dots"] .loading-dots {
2511
+ --loading-dots-size: calc(var(--studio-loader-size) * 0.22);
2512
+ --loading-dots-gap: calc(var(--studio-loader-size) * 0.11);
2513
+ --loading-dots-color: var(--studio-loader-color);
2514
+ }
2515
+
2516
+ .studio-loader[data-loader-style="bar"] {
2517
+ display: block;
2518
+ height: var(--studio-loader-bar-height, 6px);
2519
+ max-width: 100%;
2520
+ }
2521
+ .studio-loader[data-loader-style="bar"] .progress-meter {
2522
+ --progress-height: 100%;
2523
+ --progress: calc(var(--studio-loader-percent, 0) * 1%);
2524
+ --progress-fill: var(--studio-loader-color);
2525
+ --progress-track: color-mix(in srgb, var(--studio-loader-color) 16%, transparent);
2526
+ display: block;
2527
+ width: 100%;
2528
+ }
2529
+
2485
2530
  /* ---- Pending: inside a button, shown only while it or its form is busy --- */
2486
2531
  .studio-loader[data-loader-pending] { display: none; }
2487
2532
  :where(form, button)[aria-busy="true"] .studio-loader[data-loader-pending] { display: inline-grid; }
2488
2533
  :where(form, button)[aria-busy="true"] [data-loader-idle] { display: none; }
2534
+ /* The words a busy button says instead ("Saving…"): shown only while busy. */
2535
+ [data-loader-busy] { display: none; }
2536
+ :where(form, button)[aria-busy="true"] [data-loader-busy] { display: inline; margin-inline-start: 0.5rem; }
2489
2537
  .studio-loader[data-loader-pending] .studio-loader-halo { display: none; }
2490
2538
  .studio-loader[data-loader-pending] .studio-loader-icon { width: 100%; height: 100%; animation-duration: 1.4s; }
2491
2539
 
@@ -2532,6 +2580,9 @@ body.modal-open { overflow: visible; }
2532
2580
  .studio-loader-orb,
2533
2581
  .studio-loader-art { display: none; }
2534
2582
  .studio-loader-fill { --studio-loader-level: 0.45; }
2583
+ /* A still band would read as a percent; a still bar with no percent is a
2584
+ faint full fill instead. */
2585
+ .studio-loader[data-loader-style="bar"] .progress-meter--indeterminate .progress-meter-fill { width: 100%; opacity: 0.35; }
2535
2586
  }
2536
2587
 
2537
2588
  @keyframes studio-loader-breathe {
@@ -2578,3 +2629,126 @@ body.modal-open { overflow: visible; }
2578
2629
  26%, 100% { transform: scale(0.8); opacity: 0; }
2579
2630
  }
2580
2631
  }
2632
+
2633
+ /* =============================================================================
2634
+ LOADER FAMILY — skeleton, infinite scroll and pull to refresh (docs/LOADER.md).
2635
+ The branded loader above draws a wait; these three hold a page's shape while
2636
+ it waits. Markup: app/views/studio/loader/_skeleton, _infinite_scroll and
2637
+ _pull_to_refresh, through Studio::LoaderHelper. Behaviour:
2638
+ studio/infinite_scroll and studio/pull_to_refresh (lazy controllers).
2639
+
2640
+ Tune: --studio-skeleton-bone a bone's fill (default text at 9%)
2641
+ --studio-skeleton-shine the shimmer's highlight (default text at 7%)
2642
+ --studio-skeleton-width one line's width (the helper varies them)
2643
+ --studio-skeleton-speed one shimmer pass (default 1.6s)
2644
+
2645
+ The bones are tints of --color-text over whatever sits behind them, so one
2646
+ rule reads on a dark, light or glass surface. Under prefers-reduced-motion a
2647
+ skeleton is still and the pull indicator does not spin.
2648
+ ========================================================================== */
2649
+ @layer components {
2650
+ .studio-skeleton {
2651
+ --studio-skeleton-bone: color-mix(in srgb, var(--color-text) 9%, transparent);
2652
+ --studio-skeleton-shine: color-mix(in srgb, var(--color-text) 7%, transparent);
2653
+ --studio-skeleton-speed: 1.6s;
2654
+ position: relative;
2655
+ display: grid;
2656
+ gap: 1rem;
2657
+ }
2658
+
2659
+ .studio-skeleton-item { display: grid; gap: 0.6rem; align-content: start; }
2660
+ .studio-skeleton-item[data-skeleton-kind="avatar"] {
2661
+ grid-template-columns: auto 1fr;
2662
+ align-items: center;
2663
+ column-gap: 0.85rem;
2664
+ }
2665
+ .studio-skeleton-item[data-skeleton-kind="avatar"] > .studio-skeleton-lines { display: grid; gap: 0.5rem; }
2666
+ .studio-skeleton-lines { display: grid; gap: 0.55rem; }
2667
+
2668
+ .studio-skeleton-bone {
2669
+ display: block;
2670
+ border-radius: 0.375rem;
2671
+ background:
2672
+ linear-gradient(90deg, transparent 0%, var(--studio-skeleton-shine) 50%, transparent 100%) 0 0 / 200% 100% no-repeat,
2673
+ var(--studio-skeleton-bone);
2674
+ background-position: 150% 0, 0 0;
2675
+ animation: studio-skeleton-shimmer var(--studio-skeleton-speed) ease-in-out infinite;
2676
+ }
2677
+
2678
+ .studio-skeleton-bone[data-bone="line"] {
2679
+ height: 0.75rem;
2680
+ width: var(--studio-skeleton-width, 100%);
2681
+ }
2682
+ .studio-skeleton-bone[data-bone="title"] { height: 1.1rem; width: var(--studio-skeleton-width, 55%); }
2683
+ .studio-skeleton-bone[data-bone="avatar"] { width: 2.75rem; height: 2.75rem; border-radius: 9999px; }
2684
+ .studio-skeleton-bone[data-bone="media"] { width: 100%; aspect-ratio: 16 / 9; border-radius: 0.75rem; }
2685
+
2686
+ .studio-skeleton-item[data-skeleton-kind="card"] {
2687
+ padding: 0.85rem;
2688
+ border-radius: 1rem;
2689
+ border: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
2690
+ }
2691
+
2692
+ @keyframes studio-skeleton-shimmer {
2693
+ from { background-position: 150% 0, 0 0; }
2694
+ to { background-position: -50% 0, 0 0; }
2695
+ }
2696
+
2697
+ /* ---- Infinite scroll ----------------------------------------------------
2698
+ Each page arrives inside a <turbo-frame> nested in the one before. The
2699
+ frames take no box (display: contents), so the items flow into the
2700
+ list's own grid or stack as if they were one list. */
2701
+ .studio-infinite-scroll turbo-frame { display: contents; }
2702
+ .studio-infinite-scroll-more,
2703
+ .studio-infinite-scroll-end {
2704
+ grid-column: 1 / -1;
2705
+ display: flex;
2706
+ justify-content: center;
2707
+ padding-block: 1rem;
2708
+ }
2709
+ .studio-infinite-scroll-more .studio-loader { margin-inline-start: 0.5rem; }
2710
+ .studio-infinite-scroll-end { margin: 0; font-size: 0.875rem; color: var(--color-text-secondary); }
2711
+ /* Turbo marks the frame aria-busy while it fetches: the button keeps its
2712
+ place and its words, and shows a spinner beside them (a [data-loader-idle]
2713
+ child would hide while busy; "Load more" has none). */
2714
+ .studio-infinite-scroll-next[aria-busy="true"] .studio-loader[data-loader-pending] { display: inline-grid; }
2715
+ .studio-infinite-scroll-next[aria-busy="true"] [data-loader-idle] { display: none; }
2716
+
2717
+ /* ---- Pull to refresh ----------------------------------------------------
2718
+ Phone widths and touch only: the controller arms itself there and nowhere
2719
+ else, so on a desktop the indicator never shows. --studio-pull is how far
2720
+ the finger has pulled, 0..1 of the threshold. */
2721
+ .studio-pull-refresh { position: relative; }
2722
+ .studio-pull-refresh-indicator {
2723
+ --studio-pull: 0;
2724
+ position: absolute;
2725
+ top: 0;
2726
+ left: 50%;
2727
+ z-index: var(--z-raised, 10);
2728
+ display: grid;
2729
+ place-items: center;
2730
+ width: 2.5rem;
2731
+ height: 2.5rem;
2732
+ border-radius: 9999px;
2733
+ background: var(--color-surface);
2734
+ box-shadow: 0 2px 10px color-mix(in srgb, var(--color-text) 18%, transparent);
2735
+ color: var(--color-primary-ink, var(--color-primary));
2736
+ opacity: min(1, calc(var(--studio-pull) * 1.4));
2737
+ transform: translate(-50%, calc(var(--studio-pull) * 4.5rem - 3rem)) rotate(calc(var(--studio-pull) * 270deg));
2738
+ pointer-events: none;
2739
+ }
2740
+ .studio-pull-refresh-indicator[hidden] { display: none; }
2741
+ .studio-pull-refresh[data-pull-state="settling"] .studio-pull-refresh-indicator { transition: transform 0.2s ease, opacity 0.2s ease; }
2742
+ .studio-pull-refresh[data-pull-state="refreshing"] .studio-pull-refresh-indicator {
2743
+ --studio-pull: 1;
2744
+ transform: translate(-50%, 1.5rem);
2745
+ }
2746
+ .studio-pull-refresh-indicator .studio-pull-refresh-spinner { animation-play-state: paused; }
2747
+ .studio-pull-refresh[data-pull-state="refreshing"] .studio-pull-refresh-indicator .studio-pull-refresh-spinner { animation-play-state: running; }
2748
+
2749
+ @media (prefers-reduced-motion: reduce) {
2750
+ .studio-skeleton-bone { animation: none; background-position: 150% 0, 0 0; }
2751
+ .studio-pull-refresh-indicator,
2752
+ .studio-pull-refresh-indicator * { transition: none !important; animation: none !important; }
2753
+ }
2754
+ }