studio-engine 0.130.0 → 0.131.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 +151 -0
  3. data/README.md +43 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
  5. data/app/assets/tailwind/studio_engine/engine.css +587 -0
  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 +99 -0
  17. data/app/javascript/studio/controllers/carousel_controller.js +138 -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 +97 -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: f090aa3b8a00d7e7a62e7395fdc02fff3c0fdf4063350a492e1e835932dc5b26
4
+ data.tar.gz: 6d2ae853a4bb64281147c1a67721d93e1a8c38b5d3fb43e6897df85c7be964c7
5
5
  SHA512:
6
- metadata.gz: 4f0f640580ed2f77fdc6093e9a90f2edfc12bf2ba6925fc8ad6b24cd5587668c84d465090be866dcf19c5f536cb38ccbff123f078a52dbee52f8b2f7d73d302d
7
- data.tar.gz: c02e3417b8f8eb1bb4b6b38706ddf79561959b61cacb7317a9465825c1724097c3b77fc5c4cf131f0011dff852b0c430c90f660277fd0067100a611cec10ec55
6
+ metadata.gz: 7cc84fc8fad2d0b18b53e41b96e8d11bc878d8cdad732bc4a02b3581466fdafb927f3fbec54984e0ca04c839c85417008bef31f6efe21b347ebe4fce7ad7672b
7
+ data.tar.gz: 2b005e75062ef195f1178074f97efc738d798e69ee9dccbfb58cd88f1155760f4cbc1712da6bbd8de624673fce6d76a2f0982a0b8b3a58d615add5fc2f626c87
data/CHANGELOG.md CHANGED
@@ -4,6 +4,157 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.131.0 — 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - **`.studio-border-glow--clear`.** The border glow on a clear button: no fill, only the
12
+ moving rainbow ring and its halo, with the heading ink as the label (16:1 light, 18:1 dark,
13
+ measured). `<button class="btn studio-border-glow studio-border-glow--clear">`. Under
14
+ reduced motion the ring and halo of every `.studio-border-glow` stand still.
15
+ - **The loader family: the eight ways a page says wait, in one place.** From the
16
+ reel Alex McRitchie shared (task `engine-loader-family`). `studio_loader` takes
17
+ three plain styles that draw no icon: `style: :spinner`, `style: :dots` and
18
+ `style: :bar` (a progressbar; `percent:` is its value, and with none it sweeps).
19
+ They wrap the canonical `.spinner`, `.loading-dots` and `.progress-meter` rules,
20
+ so there is one set of rules; an app's own `config.loader` still refuses them.
21
+ New: `studio_skeleton` (`:lines`, `:avatar`, `:card`, `:media`; aria-busy, shapes
22
+ hidden from assistive tech, a shimmer that stops under reduced motion),
23
+ `studio_infinite_scroll` (the next page loads in a Turbo frame as the list's end
24
+ nears, "Load more" stays visible, and a polite status line counts what came) and
25
+ `studio_pull_to_refresh` (phones with touch only). `/admin/style` has a Loaders
26
+ section with all eight, and a live infinite scroll from an admin-only demo feed
27
+ (`/admin/style/loaders_feed`). Busy buttons can show their busy words with
28
+ `data-loader-busy`. Docs: `docs/LOADER.md`, "The family".
29
+ - **Agent replies show a small, safe Markdown subset.** In `studio/agent_chat`, an agent
30
+ turn's `**bold**`, `*italics*`, `` `code` ``, http(s) links, `- ` and `1. ` lists and
31
+ line breaks render as such, and a leading `> ` is dropped. The nodes are built with
32
+ `createElement` and text nodes, never HTML; any other markup, and any link that is not
33
+ http or https, shows as its own characters. A visitor's words stay plain text. Detail:
34
+ `docs/AGENT_CHAT.md`, "The page's script".
35
+ - **A spotlight style for the carousel: `studio/_carousel`, `style: :spotlight`.** The
36
+ same carousel, arrows, dots and keys, but each slide snaps to the centre. The centred
37
+ slide is full size and colour, with an optional `caption:` line under it (a callable on
38
+ the item); its neighbours are smaller (0.75), faded and grey, and the next ones peek at
39
+ the edges. An arrow or key moves one slide, and every slide gets a dot. Where the browser
40
+ has scroll-driven animations (`animation-timeline: view()`), each slide's own view
41
+ timeline drives the look, so it follows the finger; elsewhere the controller marks the
42
+ centred slide `data-current` and CSS transitions it. Reduced motion scales and greys
43
+ nothing: the neighbours are only dimmed. An unknown `style:` raises. Specimen on
44
+ `/admin/style`, Tricks.
45
+ - **Talk to an agent in a modal: `studio/agent_chat/_talk_modal`.** Render it once on a page,
46
+ then mark any link with `studio_talk_to_agent_link(label, href:, soul:, topic:, opener:)`. A
47
+ click opens the shared agent chat in a card on the page-scoped modal host and sends the
48
+ opener as the visitor's first message once the thread loads, so the agent is already
49
+ answering while the card opens. A new answer types itself in. It is the same chat and the
50
+ same hub thread as `studio/agent_chat/chat`, not a second one. The opener goes once per soul
51
+ and topic per tab. A modified click, a page with no modal, or a viewer
52
+ `agent_chat_allowed` refuses follows the link's `href`. The chat takes two new section
53
+ attributes, `data-agent-chat-opener` (with `-opener-key`) and `data-agent-chat-reveal`, and
54
+ the partial takes `compact: true`. Detail: `docs/AGENT_CHAT.md`, "Talk to an agent in a
55
+ modal".
56
+ - **A crumb trail on the page header, from one parent map.** `studio/_page_header` takes
57
+ `crumbs:` (the page's ancestors, root first) and `crumb_label:`. The trail sits above the
58
+ title: muted ancestor links, the page itself bold, a chevron between. Below 40rem it gives
59
+ way to one "‹ Parent" link. An app names each parent page once in `Studio.crumbs.draw`
60
+ (`page :deployments, "Deployments", :deployments_path, parent: :admin`), and a view calls
61
+ `studio_crumbs(:key)` or `studio_crumbs(parent: :key)`. The row also takes `links:`, the
62
+ page's other links on a line at its foot that sticks with it, and renders `actions_html`
63
+ after the tabs when it has both. Detail: `docs/PAGE_HEADER.md`.
64
+ - **The gliding pill.** The page header's open tab is one pill, anchored by CSS anchor
65
+ positioning to the tab with `aria-current`, so it glides to the next tab when the scroll spy
66
+ or a click moves `aria-current`. No script measures it. Reduced motion jumps. A browser
67
+ without anchor positioning keeps each tab's own fill.
68
+
69
+ - **A carousel standard for the apps: `studio/_carousel`.** Render it as a layout, with
70
+ `label:` and `items:`, and the block draws each slide, so a slide is any block content.
71
+ The track is a native scroll-snap row: a swipe, a trackpad and a focused link move it with
72
+ no script. A wide row shows `per_view:` slides (default 3), a medium row up to two and a
73
+ phone one, each with a sliver of the next; the widths come from container queries on the
74
+ region, not the window. The lazy `carousel` controller adds the edge fade, arrows that
75
+ set `aria-disabled` at an end, dots that hide past the last stop, the arrow keys with Home
76
+ and End, and a polite status line after a press. It follows the APG carousel pattern:
77
+ a labelled region with `aria-roledescription="carousel"`, and each slide a labelled
78
+ group (`slide_label:` names it from its item). It honours `prefers-reduced-motion`, and
79
+ its arrows take the card fill, so they are glass under the glass surfaces. Until the
80
+ controller connects, the controls stay hidden and the row keeps a thin scrollbar. New
81
+ classes: `carousel`, `carousel-track`, `carousel-slide`, `carousel-controls`,
82
+ `carousel-dots`, `carousel-dot`, `carousel-arrows` and `carousel-button`. `/admin/style`
83
+ shows it under Tricks.
84
+
85
+ ### Changed
86
+
87
+ - **Back plays the app's page move the other way.** Browser Back (a Turbo restore that Turbo
88
+ marks `back`, or a full load that traverses to an earlier entry) and a click on a link with
89
+ `data-page-transition-back` (every ancestor crumb and the phone's parent link) play the
90
+ reverse direction in the same style: a `:left` slide goes back as `slide-right`, an
91
+ `:unfold` with `:right` as `unfold-right`. Forward keeps the app's own direction. Detail:
92
+ `docs/PAGE_TRANSITIONS.md`, "Back".
93
+ - **The page header's title is the type scale's h2 from 40rem** (30px, weight 800), Alex
94
+ McRitchie's "H2 header"; a phone keeps 1.5x the base. It is still the page's one `h1`.
95
+
96
+ ### Fixed
97
+
98
+ - **Review follow-ups on the sidebar, the carousel and the page header** (/tasks/engine-ui-polish-follow-ups).
99
+ The link sidebar's focus ring (its rows, close button, Log out, trigger and geo rows) is
100
+ `ring-primary-ink`, a new ring colour from the theme's primary ink: measured 5.4 to 8.9:1
101
+ beside every surface in dark, light and glass on five palettes, from 1.3 to 2.0:1 for
102
+ `ring-primary/40`. A carousel arrow or key that goes nowhere scrolls and announces nothing;
103
+ keys inside a slider, listbox, combobox, tab list, other arrow-key widget or nested carousel
104
+ stay theirs; and the focusable track is a group named "<label> slides". The page header row
105
+ is now always a `<div>`: with tabs, the tabs are their own `<nav>` named by `label`, beside
106
+ the crumb and links navs, so no landmark nests in another (a host test that found the row by
107
+ `nav[aria-label=…]` now finds the tabs). The `/admin/style` specimen adds no landmark.
108
+ The current link under the title is bold as well as coloured, and the pill's track sets
109
+ `anchor-scope`. `studio_crumbs` with a key the map lacks still raises in development and
110
+ test; elsewhere it logs to ErrorLog and draws no trail, not a 500. The dead redirect guard
111
+ in `studio/page_transitions` is gone.
112
+ - **`/admin/style`: Tricks is second, and nothing in the header is cut off.** The sections and
113
+ their pills run Theme, Tricks, Loaders, Modals, Tasks, Components (a host's own modals still
114
+ follow Modals). The tab scroller gives its pills 4px of room on every side, so their shadow, lit
115
+ edge and focus ring show, and the last pill's right edge too; phones still scroll sideways.
116
+ Each specimen card has one Copy (the snippet's, or the class name's when there is no
117
+ snippet), and the code wraps clear of it.
118
+ - **The talk modal's opener is never dropped.** A visitor who sent a message before the
119
+ thread first loaded lost the opener: `studio/agent_chat` marked it sent, then the
120
+ post returned early because the visitor's send was in flight. Now a send made
121
+ before the load posts the opener first, then the visitor's words; a send made while
122
+ the opener is in flight waits for it; and the tab remembers the opener only as its
123
+ POST starts, forgetting it if the POST fails. It is never sent twice.
124
+ - **`e2e/talk_to_agent.spec.js`'s modified-click spec no longer flakes.** It waited for
125
+ the new tab a Ctrl+click opens, which on CI's headless Linux Chromium stayed at
126
+ `about:blank` (it read "blank", and a wait for its URL timed out), so it measured the
127
+ browser rather than the modal. It now asserts what the modal owes a modified click:
128
+ the click is not prevented (the browser follows the `href`) and no card opens.
129
+ - **A signup that cannot send its email no longer fails silently.** `RegistrationsController#create`
130
+ ended in a `rescue StandardError` that logged nothing and rendered `registrations/new`. On a
131
+ passwordless app `@user` was never set, so a failed link mint or mail raised `NoMethodError` on
132
+ nil in that view, and no ErrorLog row named the first failure. Now a passwordless failure is
133
+ logged through `rescue_and_log` and redirects to `/signup` with one friendly alert for every
134
+ address, so it reveals nothing about which emails have accounts. The account is still created
135
+ only when the link is used. A password signup re-renders the form on a validation error and
136
+ writes no ErrorLog row. A POST with no `user` params gets the form too, not a
137
+ `ParameterMissing`. Any other raise is logged once and goes to the catch-all. The rescue no
138
+ longer names the unsaved user as its target: ErrorLog's `belongs_to` autosaved it, which ran the
139
+ failing save again inside the logger. That second raise skipped the logged mark, so the
140
+ catch-all wrote the same failure twice.
141
+ - **`rescue_and_log` writes one ErrorLog row per failure, also for an unsaved target.** It
142
+ linked `target:` with ErrorLog's `belongs_to`, which saves a new record. With an unsaved
143
+ record, the logger ran the failing save a second time. That raise skipped the logged mark,
144
+ so the catch-all wrote the same failure again, and a save that could pass created the
145
+ record. Now an unsaved target or parent is named on the row by its slug (else
146
+ `unsaved User`) and not linked. This covers the SSO, wallet and link sign-ups and the theme save. When the
147
+ ErrorLog write itself fails, in `rescue_and_log` or in the catch-all, `Rails.logger.error`
148
+ records both errors. `rescue_and_log` still raises the original error, and the catch-all
149
+ still gives its friendly reply. Before, the logger's error replaced both.
150
+ - **`POST /magic_link` answers a failed mint or mail the same way for every address.** The
151
+ raise went to the catch-all: "Something went wrong" on the home page, or a JSON 500. Now
152
+ the failure is logged once and the visitor goes back to `/login` with one alert. JSON gets
153
+ `503` with `{ success: false, error: "delivery_failed", message: … }`: the input was
154
+ fine and a retry can work, so it is not a 4xx. The body does not carry the internal
155
+ message, and the action never looks an account up, so it reveals nothing about which
156
+ emails have accounts. `Studio::MagicLinkIssuing::DELIVERY_FAILED` holds the words.
157
+
7
158
  ## 0.130.0 — 2026-11-05
8
159
 
9
160
  ### 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,32 @@ 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
+ `style: :spotlight` snaps each slide to the centre: the centred slide is full size and
332
+ colour, with its `caption:` line (a callable on the item) under it, and its neighbours
333
+ are smaller, faded and grey. The arrows and keys move one slide. Where the browser has
334
+ scroll-driven animations the look follows the scroll; elsewhere the controller marks the
335
+ centred slide (`data-current`) and CSS transitions it. Reduced motion only dims.
336
+
337
+ ```erb
338
+ <%= render layout: "studio/carousel",
339
+ locals: { label: "Team", items: @people, style: :spotlight, caption: ->(person) { person.title } } do |person| %>
340
+ <%= render "people/card", person: person %>
341
+ <% end %>
342
+ ```
343
+
307
344
  ### The "at" time stamp — `at_time_tag`
308
345
 
309
346
  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
+ }