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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f090aa3b8a00d7e7a62e7395fdc02fff3c0fdf4063350a492e1e835932dc5b26
|
|
4
|
+
data.tar.gz: 6d2ae853a4bb64281147c1a67721d93e1a8c38b5d3fb43e6897df85c7be964c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
294
|
-
|
|
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
|
+
}
|