studio-engine 0.131.0 → 0.133.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 +96 -1
- data/README.md +65 -28
- data/app/assets/tailwind/studio_engine/engine.css +158 -2
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/carousel.js +74 -1
- data/app/javascript/studio/controllers/carousel_controller.js +163 -9
- data/app/javascript/studio/controllers/page_header_controller.js +64 -1
- data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
- data/app/javascript/studio/in_page_links.js +308 -0
- data/app/javascript/studio/page_header.js +17 -0
- data/app/views/studio/_carousel.html.erb +54 -11
- data/app/views/studio/_page_header.html.erb +64 -12
- data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
- data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
- data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
- data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
- data/app/views/style/_glass.html.erb +4 -8
- data/app/views/style/_loaders.html.erb +11 -35
- data/app/views/style/_modals.html.erb +29 -39
- data/app/views/style/_scrims.html.erb +10 -20
- data/app/views/style/_site_footer.html.erb +10 -18
- data/app/views/style/_specimen.html.erb +13 -41
- data/app/views/style/_tasks.html.erb +4 -22
- data/app/views/style/_trick_label.html.erb +33 -0
- data/app/views/style/_tricks.html.erb +127 -207
- data/app/views/style/index.html.erb +2 -2
- data/app/views/style/modals/_wallet_connect.html.erb +2 -1
- data/config/studio_tricks.yml +470 -0
- data/lib/studio/page_header.rb +53 -0
- data/lib/studio/tricks.rb +110 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +6 -4
- metadata +9 -3
- data/app/views/style/modals/_ds_email_field.html.erb +0 -58
- data/app/views/style/modals/_ds_resend_footer.html.erb +0 -70
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7aa30f915a5a06beddbc97ad855340b80114d9875dab7d43669596202a62f197
|
|
4
|
+
data.tar.gz: 5737966bb7427575a76320d618e5a1221b747625c6e8393a4f2a4ede7ed4a306
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aed3464a127458df847bcebd599605b0816e1698803c36aaa850c287cc73848c40454cd794968cf0ea4359e6f22bc3a3d56919d442c79e70dabb32fd2c0bb1de
|
|
7
|
+
data.tar.gz: fed96340b5100692ff438bf1e40de11edee35cf3bed69260ef93520d0e004454cb4f4895cedd55c09112275dbb334b5a529796486f2bd943a92dd8019671b530
|
data/CHANGELOG.md
CHANGED
|
@@ -4,10 +4,97 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.133.0 — 2026-11-06
|
|
8
|
+
|
|
9
|
+
### Deprecated
|
|
10
|
+
|
|
11
|
+
- **`studio/_page_header`'s `actions_html:`.** It still renders, before the menu, for one
|
|
12
|
+
more release. In development the first render from each call site writes one warning to
|
|
13
|
+
the log. Move each control to `tabs:`, `menu:`, `action:`, `tools_html:` or `meta:`.
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **A link to a place on the same page scrolls there. It no longer loads the page again.**
|
|
17
|
+
Alex McRitchie, on the style guide's tabs: "The system needs to understand when a scroll
|
|
18
|
+
down is the solution rather than a page change." Turbo 8 made a click on `#tricks` a full
|
|
19
|
+
visit: a fetch, a render and the page transition. `studio/in_page_links` now takes a plain
|
|
20
|
+
click on a link to this page's path and query with a `#fragment` whose element exists. It
|
|
21
|
+
smooth-scrolls to the element under the pinned stack (a jump under reduced motion), pushes
|
|
22
|
+
the fragment so Back and a shared link work, and moves focus to the target. Cross-page
|
|
23
|
+
links, modifier clicks, `target`, `download` and `data-turbo="false"` are untouched. The
|
|
24
|
+
style guide's tab highlight holds the clicked tab until the scroll settles
|
|
25
|
+
(docs/PAGE_HEADER.md, "In-page links"; task `in-page-links-scroll`).
|
|
26
|
+
- **The style guide's cards carry trick names, not markup snippets.** Alex McRitchie,
|
|
27
|
+
2026-11-06: "I really just need to have a good naming convention so I can convey an
|
|
28
|
+
idea to my agents." Every card under Tricks, Loaders and Tasks on `/admin/style` now
|
|
29
|
+
shows a short name as its heading ("Sheen", "Border Glow", "Hold"), a one-line note,
|
|
30
|
+
and its class or helper small and muted. A variant is the name, a middle dot and a
|
|
31
|
+
lower-case word ("Sheen · loop", "Border Glow · clear", "Hold · recolor"), and
|
|
32
|
+
variants of one primitive share a card, side by side on its stage: Tricks and
|
|
33
|
+
Loaders went from 79 cards to 58. The usage `<pre>` and its Copy are gone; the one Copy copies the
|
|
34
|
+
agent line, for example `Sheen — .sheen; Sheen · loop — .sheen--loop`. One registry,
|
|
35
|
+
`config/studio_tricks.yml` (`Studio::Tricks`), feeds the cards and the generated
|
|
36
|
+
glossary `docs/TRICKS.md` (`bin/tricks-doc`; `test/lib/tricks_registry_test.rb`
|
|
37
|
+
fails when they drift, a name repeats, or a token names a class, helper, partial or
|
|
38
|
+
script the engine does not ship). `style/_specimen` takes `trick:` (and `variant:`)
|
|
39
|
+
in place of `klass:`, `usage:` and `note:`. The Tricks branded-loader group no longer
|
|
40
|
+
repeats the busy button and the overlay trigger: they are the Loaders section's Busy
|
|
41
|
+
Button and Overlay (task `tricks-named-not-snippets`).
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **The page header's four kinds of control.** Alex McRitchie, 2026-11-06: the
|
|
46
|
+
/deployments header showed a grey pill, a purple button and a black button side by
|
|
47
|
+
side. `studio/_page_header` now takes the right side as four kinds, each with one look,
|
|
48
|
+
in one fixed order and at one height: `tabs:` (views of the same thing) or `anchors:`
|
|
49
|
+
(sections of this page, the same pill group), `menu:` (pages elsewhere, behind one
|
|
50
|
+
neutral "Links" button that opens a popover list; Escape closes it and focus returns),
|
|
51
|
+
`action:` (the one primary button) and `tools_html:` (local-only controls, muted). A
|
|
52
|
+
small primary button in the row takes a clear 1px border, so it stands at a neutral
|
|
53
|
+
button's height. `docs/PAGE_HEADER.md` gives the decision rule for which kind a link is,
|
|
54
|
+
and the style guide's Page header specimen shows all four (task
|
|
55
|
+
`page-header-control-kinds`).
|
|
56
|
+
|
|
57
|
+
- **A carousel can loop and play itself: `loop:` and `autoplay:`.** Alex McRitchie wants
|
|
58
|
+
the hub's Portfolio spotlight "auto scrolling and in a loop". `loop: true` wraps the
|
|
59
|
+
arrows and keys (last to first, first to last) and keeps both arrows enabled, with no
|
|
60
|
+
cloned slides. `autoplay: <seconds>` moves one slide each interval in `:standard` and
|
|
61
|
+
`:spotlight`; it holds on hover over the slides, on keyboard focus, in a hidden tab, off
|
|
62
|
+
screen, and after any swipe or press until the reader is idle; it starts stopped under
|
|
63
|
+
reduced motion and never writes the status line. A pause button (`.carousel-autoplay`,
|
|
64
|
+
"Pause slideshow" / "Play slideshow") meets WCAG 2.2.2. Both default off, so every
|
|
65
|
+
existing carousel is unchanged (task `carousel-autoplay-and-loop`).
|
|
66
|
+
- **The engine ships a standard sign-in card.** Alex McRitchie, on the style guide's
|
|
67
|
+
Email field specimen: "this should use the standard auth component with Google and
|
|
68
|
+
Email." `studio/auth/_sign_in_card` (a page) and `studio/auth/_sign_in_modal` (a
|
|
69
|
+
modal host's template) render Google, then "or", then the email magic link; Send
|
|
70
|
+
flips the card to a link-sent step with the shared resend footer. Locals: `heading`,
|
|
71
|
+
`subheading` (default "Sign in to" the app name), `return_to` (a plain in-app path
|
|
72
|
+
only; it rides the magic-link POST and the Google request URL), `preamble` and
|
|
73
|
+
`extras` slots for an age attestation or extra providers, and `demo`. Real use
|
|
74
|
+
posts Google to `/auth/google_oauth2` (`button_to` POST, Turbo off) and the email to
|
|
75
|
+
`POST /magic_link`, and reads the `503 delivery_failed` answer. `demo: true` posts
|
|
76
|
+
nothing. `/admin/style` > Modals > Auth is now one "Sign in" specimen that opens
|
|
77
|
+
the card in demo mode; the separate Email field and Resend footer specimens are
|
|
78
|
+
gone, and both partials stay. `studio/modals/auth/_resend_footer` takes
|
|
79
|
+
`close: false` for a host that is not a modal. The hub and turf-monster still
|
|
80
|
+
render their own cards; adoption is follow-up work. This reverses the 2026-09-09
|
|
81
|
+
call (PR #319) that the engine ships only the fragments, not a card. Docs: `docs/SIGN_IN_CARD.md`
|
|
82
|
+
(task `engine-ships-sign-in-card`).
|
|
83
|
+
|
|
84
|
+
## 0.132.0 — 2026-11-06
|
|
85
|
+
|
|
7
86
|
## 0.131.0 — 2026-11-05
|
|
8
87
|
|
|
9
88
|
### Added
|
|
10
89
|
|
|
90
|
+
- **A carousel opens where you tell it: `start:`, and a spotlight opens on its middle
|
|
91
|
+
slide.** Alex McRitchie saw an empty left half on a spotlight's first slide. `start:`
|
|
92
|
+
takes an index or `:middle`; it defaults to `:middle` for `style: :spotlight` and to 0
|
|
93
|
+
for a standard row, so a standard row is unchanged. The slide is the browser's
|
|
94
|
+
`scroll-initial-target`, so Chromium opens there before the first paint; elsewhere the
|
|
95
|
+
controller jumps there on connect, with no glide and an empty status line. The dots,
|
|
96
|
+
`data-current` and both arrows' `aria-disabled` are right from the server. A
|
|
97
|
+
`#fragment` inside the row wins over the start (task `carousel-start-and-button-glass`).
|
|
11
98
|
- **`.studio-border-glow--clear`.** The border glow on a clear button: no fill, only the
|
|
12
99
|
moving rainbow ring and its halo, with the heading ink as the label (16:1 light, 18:1 dark,
|
|
13
100
|
measured). `<button class="btn studio-border-glow studio-border-glow--clear">`. Under
|
|
@@ -36,7 +123,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
36
123
|
same carousel, arrows, dots and keys, but each slide snaps to the centre. The centred
|
|
37
124
|
slide is full size and colour, with an optional `caption:` line under it (a callable on
|
|
38
125
|
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
|
|
126
|
+
the edges. An arrow ("Previous slide", "Next slide") or key moves one slide, and every
|
|
127
|
+
slide gets a dot. It opens on its middle slide (`start:`, below). Where the browser
|
|
40
128
|
has scroll-driven animations (`animation-timeline: view()`), each slide's own view
|
|
41
129
|
timeline drives the look, so it follows the finger; elsewhere the controller marks the
|
|
42
130
|
centred slide `data-current` and CSS transitions it. Reduced motion scales and greys
|
|
@@ -84,6 +172,11 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
84
172
|
|
|
85
173
|
### Changed
|
|
86
174
|
|
|
175
|
+
- **A dark, full-size lit button's fill drops from 0.85 to 0.65 alpha** (`--lit-fill-alpha`),
|
|
176
|
+
Alex McRitchie's "even more transparent". White label on the primary, measured from painted
|
|
177
|
+
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
|
|
178
|
+
backdrop (#808080) 3.25 to 3.42, a lighter one (#a0a0a0) 3.06 to 2.98. Light pages stay solid
|
|
179
|
+
(3.10).
|
|
87
180
|
- **Back plays the app's page move the other way.** Browser Back (a Turbo restore that Turbo
|
|
88
181
|
marks `back`, or a full load that traverses to an earlier entry) and a click on a link with
|
|
89
182
|
`data-page-transition-back` (every ancestor crumb and the phone's parent link) play the
|
|
@@ -95,6 +188,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
95
188
|
|
|
96
189
|
### Fixed
|
|
97
190
|
|
|
191
|
+
- **A standard carousel's `caption:` is a styled line** (small body text under each slide),
|
|
192
|
+
not bare text; the partial's docs and the README say which style shows which captions.
|
|
98
193
|
- **Review follow-ups on the sidebar, the carousel and the page header** (/tasks/engine-ui-polish-follow-ups).
|
|
99
194
|
The link sidebar's focus ring (its rows, close button, Log out, trigger and geo rows) is
|
|
100
195
|
`ring-primary-ink`, a new ring colour from the theme's primary ink: measured 5.4 to 8.9:1
|
data/README.md
CHANGED
|
@@ -30,7 +30,7 @@ resolved.
|
|
|
30
30
|
|
|
31
31
|
## What It Provides
|
|
32
32
|
|
|
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
|
|
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; one standard sign-in card (Google, or, email magic link) as a page partial and a modal ([`docs/SIGN_IN_CARD.md`](docs/SIGN_IN_CARD.md))
|
|
34
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`.
|
|
@@ -293,18 +293,24 @@ actions; the engine supplies only the banner.
|
|
|
293
293
|
### The page header — `studio/_page_header`
|
|
294
294
|
|
|
295
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,
|
|
297
|
-
|
|
296
|
+
`studio_crumbs`) above an h2-size title, the right side's four kinds of control (tabs or
|
|
297
|
+
anchors with a gliding pill, a Links menu, one action, then tools, in that order and at one
|
|
298
|
+
height), and the page's other links on a line under it. A plain header at rest, a bar once stuck under
|
|
298
299
|
the navbar, in the pinned stack so a table's sticky header sits under it
|
|
299
300
|
([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md)).
|
|
300
301
|
|
|
301
302
|
```erb
|
|
302
303
|
<%= render "studio/page_header", title: "Deployments", crumbs: studio_crumbs(:deployments),
|
|
303
|
-
label: "Views", tabs: [{ label: "Board", href: deployments_path, current: "page" }
|
|
304
|
-
|
|
304
|
+
label: "Views", tabs: [{ label: "Board", href: deployments_path, current: "page" },
|
|
305
|
+
{ label: "Triage", href: triage_path }],
|
|
306
|
+
menu: [{ label: "Stages", href: stages_path }, { label: "Docs", href: docs_path }],
|
|
307
|
+
action: { label: "New Task", href: new_task_path },
|
|
305
308
|
links: [{ label: "Epics", href: epics_path }] %>
|
|
306
309
|
```
|
|
307
310
|
|
|
311
|
+
`actions_html:` is deprecated for one release; the decision rule for which kind a link is
|
|
312
|
+
lives in [`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md#the-four-kinds-of-control).
|
|
313
|
+
|
|
308
314
|
### The section header — `studio/_section_header`
|
|
309
315
|
|
|
310
316
|
A section's title that sticks under the page row until the next section's takes its place,
|
|
@@ -328,11 +334,40 @@ as a layout; the block draws each slide. `per_view:` is the wide row's count
|
|
|
328
334
|
<% end %>
|
|
329
335
|
```
|
|
330
336
|
|
|
337
|
+
`caption:` (a callable on the item) writes a small line under each slide.
|
|
338
|
+
|
|
331
339
|
`style: :spotlight` snaps each slide to the centre: the centred slide is full size and
|
|
332
|
-
colour, with its
|
|
333
|
-
are smaller, faded and grey. The arrows
|
|
334
|
-
|
|
335
|
-
centred slide (`data-current`) and CSS
|
|
340
|
+
colour, with its caption under it (the spotlight shows the centred slide's only), and its
|
|
341
|
+
neighbours are smaller, faded and grey. The arrows ("Previous slide", "Next slide") and
|
|
342
|
+
keys move one slide. Where the browser has scroll-driven animations the look follows the
|
|
343
|
+
scroll; elsewhere the controller marks the centred slide (`data-current`) and CSS
|
|
344
|
+
transitions it. Reduced motion only dims.
|
|
345
|
+
|
|
346
|
+
`start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight opens
|
|
347
|
+
on its middle slide by default, so neither half of the row is empty at load; a standard
|
|
348
|
+
row opens on its first. The browser sets it before the first paint where it has
|
|
349
|
+
`scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
|
|
350
|
+
glide and nothing announced. A `#fragment` link to an element inside the row wins.
|
|
351
|
+
|
|
352
|
+
`loop: true` wraps the row: Next at the last slide goes to the first, Previous at the first
|
|
353
|
+
to the last, and neither arrow is ever disabled. No slide is cloned, so a screen reader reads
|
|
354
|
+
each one once; the row glides back across itself.
|
|
355
|
+
|
|
356
|
+
`autoplay:` is the seconds between moves (default `nil`, off). The row moves one slide each
|
|
357
|
+
interval, in both styles. It holds still while the pointer is over the slides, while keyboard
|
|
358
|
+
focus is in the row, while the tab is hidden or the row is off screen, and after a swipe,
|
|
359
|
+
wheel, arrow, dot or key until the reader leaves it alone for one interval (never under five
|
|
360
|
+
seconds). A pause button ("Pause slideshow", then "Play slideshow") sits before the arrows,
|
|
361
|
+
for WCAG 2.2.2. Under reduced motion the row starts stopped, and Play starts it. Autoplay
|
|
362
|
+
never writes the status line. Without `loop:` it stops at the last slide, and Play starts
|
|
363
|
+
again from the first.
|
|
364
|
+
|
|
365
|
+
```erb
|
|
366
|
+
<%= render layout: "studio/carousel",
|
|
367
|
+
locals: { label: "Portfolio", items: @apps, style: :spotlight, autoplay: 5, loop: true } do |app| %>
|
|
368
|
+
<%= render "apps/card", app: app %>
|
|
369
|
+
<% end %>
|
|
370
|
+
```
|
|
336
371
|
|
|
337
372
|
```erb
|
|
338
373
|
<%= render layout: "studio/carousel",
|
|
@@ -612,23 +647,22 @@ inline transport nothing listens, so the dispatch is a harmless no-op there.
|
|
|
612
647
|
|
|
613
648
|
#### The auth modal's credential slot
|
|
614
649
|
|
|
615
|
-
**This engine ships
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
(PR #319)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
README can settle.
|
|
650
|
+
**This engine ships the standard sign-in card:** `studio/auth/_sign_in_card`
|
|
651
|
+
(a page) and `studio/auth/_sign_in_modal` (a modal), Google, then "or", then the
|
|
652
|
+
email magic link, built from `studio/modals/shared/_email_field` and
|
|
653
|
+
`studio/modals/auth/_resend_footer` ([`docs/SIGN_IN_CARD.md`](docs/SIGN_IN_CARD.md)).
|
|
654
|
+
It is the one authority `lib/studio.rb` cites beside the Solana routes. It
|
|
655
|
+
reverses the 2026-09-09 call (PR #319) that the engine ships only the fragments
|
|
656
|
+
(Alex McRitchie, 2026-11-06). Until the apps adopt it, each still renders its
|
|
657
|
+
own card: turf-monster's `app/views/modals/_auth.html.erb` computes its wallet
|
|
658
|
+
button from `auth_methods` and `feature?(:web3)`.
|
|
659
|
+
|
|
660
|
+
**The wallet credential slot is the card's `extras:` local.** solana-studio
|
|
661
|
+
still ships `solana_studio/auth/_wallet_credential`, a Solana button written to
|
|
662
|
+
be *contributed* into a sign-in card rather than copied into one. The engine
|
|
663
|
+
card takes extra providers as named partials, after Google and before "or", so
|
|
664
|
+
an app passes `extras: "solana_studio/auth/wallet_credential"` (or its own
|
|
665
|
+
wallet button). Turf's adoption decides which one it keeps.
|
|
632
666
|
|
|
633
667
|
**A card that does host it owns the gate.** The retired mirror's rules still
|
|
634
668
|
hold; they simply live in that app's view now. Two layers answer two different
|
|
@@ -822,8 +856,11 @@ than assuming drop-in equivalence:
|
|
|
822
856
|
### Style guide — your app's own section
|
|
823
857
|
|
|
824
858
|
`/admin/style` is the engine's living style guide, and every section on it is
|
|
825
|
-
the engine's: Theme, Modals, Tricks, Tasks.
|
|
826
|
-
|
|
859
|
+
the engine's: Theme, Modals, Tricks, Tasks. Each card under Tricks, Loaders and
|
|
860
|
+
Tasks carries a short trick name ("Sheen", "Border Glow · clear") that you can say
|
|
861
|
+
to an agent: [`docs/TRICKS.md`](./docs/TRICKS.md) maps every name to its class or
|
|
862
|
+
helper, generated from `config/studio_tricks.yml` by `bin/tricks-doc`. An app grows a
|
|
863
|
+
**fifth section, its own**, by defining one partial:
|
|
827
864
|
|
|
828
865
|
A host may also define `app/views/style/host/_logos.html.erb`, which replaces the Theme section's
|
|
829
866
|
default logo grid.
|
|
@@ -377,6 +377,13 @@ body.modal-open .studio-app-banner {
|
|
|
377
377
|
|
|
378
378
|
@utility page-header-actions {
|
|
379
379
|
flex-shrink: 0;
|
|
380
|
+
/* The fixed order of the right side (docs/PAGE_HEADER.md, "The four kinds of control"):
|
|
381
|
+
the menu, then the action, then the tools. Each is a .btn-sm, so all share one height. */
|
|
382
|
+
display: flex;
|
|
383
|
+
flex-wrap: wrap;
|
|
384
|
+
align-items: center;
|
|
385
|
+
justify-content: flex-end;
|
|
386
|
+
gap: 0.5rem;
|
|
380
387
|
/* Never wider than the row: a group of controls wraps inside it on a phone, and the
|
|
381
388
|
page does not scroll sideways. */
|
|
382
389
|
max-width: 100%;
|
|
@@ -388,6 +395,116 @@ body.modal-open .studio-app-banner {
|
|
|
388
395
|
}
|
|
389
396
|
}
|
|
390
397
|
|
|
398
|
+
/* ONE CONTROL HEIGHT. A .btn-neutral carries a 1px border and a .btn-primary none, so side
|
|
399
|
+
by side the neutral one stood 2px taller (30px to 28px, measured in the e2e lab). In the
|
|
400
|
+
row every small primary takes a clear border of the same width, so a tab, the menu, the
|
|
401
|
+
action and a tool all stand at one height. */
|
|
402
|
+
.page-header .btn-sm.btn-primary {
|
|
403
|
+
border: 1px solid transparent;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* THE QUICK-LINKS MENU: related pages elsewhere, behind one neutral "Links" button. The
|
|
407
|
+
list is the browser's popover, so Escape, a click outside and the focus return need no
|
|
408
|
+
script. Where the browser has anchor positioning, CSS hangs the list under the button's
|
|
409
|
+
right edge; elsewhere the page-header controller places it (studio/page_header,
|
|
410
|
+
menuPlacement). anchor-scope keeps each row's button its own anchor. */
|
|
411
|
+
@utility page-header-menu {
|
|
412
|
+
position: relative;
|
|
413
|
+
anchor-scope: --page-header-menu;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
@utility page-header-menu-button {
|
|
417
|
+
gap: 0.375rem;
|
|
418
|
+
anchor-name: --page-header-menu;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* A chevron that points down, and up while the list is open. CSS, so a reader hears the
|
|
422
|
+
button's name alone. */
|
|
423
|
+
@utility page-header-menu-caret {
|
|
424
|
+
width: 0.375rem;
|
|
425
|
+
height: 0.375rem;
|
|
426
|
+
border-right: 1.5px solid currentColor;
|
|
427
|
+
border-bottom: 1.5px solid currentColor;
|
|
428
|
+
transform: translateY(-0.125rem) rotate(45deg);
|
|
429
|
+
transition: transform 150ms ease;
|
|
430
|
+
|
|
431
|
+
.page-header-menu:has(> .page-header-menu-list:popover-open) & {
|
|
432
|
+
transform: translateY(0.125rem) rotate(225deg);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@media (prefers-reduced-motion: reduce) {
|
|
436
|
+
transition: none;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@utility page-header-menu-list {
|
|
441
|
+
/* The popover's own styles centre it on the screen; this is a dropdown. */
|
|
442
|
+
position: fixed;
|
|
443
|
+
inset: auto;
|
|
444
|
+
margin: 0;
|
|
445
|
+
min-width: 10rem;
|
|
446
|
+
max-width: min(20rem, calc(100vw - 2rem));
|
|
447
|
+
padding: 0.25rem;
|
|
448
|
+
@apply rounded-lg border border-subtle bg-surface text-body shadow-lg;
|
|
449
|
+
|
|
450
|
+
& ul {
|
|
451
|
+
margin: 0;
|
|
452
|
+
padding: 0;
|
|
453
|
+
list-style: none;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@supports (anchor-name: --a) and (position-anchor: --a) {
|
|
457
|
+
position-anchor: --page-header-menu;
|
|
458
|
+
top: anchor(bottom);
|
|
459
|
+
right: anchor(right);
|
|
460
|
+
margin-top: 0.25rem;
|
|
461
|
+
position-try-fallbacks: flip-block, flip-inline;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
@utility page-header-menu-item {
|
|
466
|
+
display: block;
|
|
467
|
+
padding: 0.5rem 0.75rem;
|
|
468
|
+
border-radius: 0.375rem;
|
|
469
|
+
font-size: calc(var(--type-base, 16px) * 0.875);
|
|
470
|
+
font-weight: 500;
|
|
471
|
+
text-decoration: none;
|
|
472
|
+
white-space: nowrap;
|
|
473
|
+
overflow: hidden;
|
|
474
|
+
text-overflow: ellipsis;
|
|
475
|
+
@apply text-body;
|
|
476
|
+
|
|
477
|
+
&:hover,
|
|
478
|
+
&:focus-visible {
|
|
479
|
+
@apply bg-surface-alt text-heading;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
&:focus-visible {
|
|
483
|
+
outline: 2px solid var(--color-primary);
|
|
484
|
+
outline-offset: -2px;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* THE TOOLS: local-only and dev controls, after the action. Muted, behind a dashed rule, so
|
|
489
|
+
they never read as the page's own controls; full strength while a pointer or focus is on
|
|
490
|
+
them. */
|
|
491
|
+
@utility page-header-tools {
|
|
492
|
+
display: flex;
|
|
493
|
+
flex-wrap: wrap;
|
|
494
|
+
align-items: center;
|
|
495
|
+
gap: 0.5rem;
|
|
496
|
+
padding-left: 0.5rem;
|
|
497
|
+
border-left: 1px dashed;
|
|
498
|
+
@apply border-subtle;
|
|
499
|
+
opacity: 0.7;
|
|
500
|
+
transition: opacity 150ms ease;
|
|
501
|
+
|
|
502
|
+
&:hover,
|
|
503
|
+
&:focus-within {
|
|
504
|
+
opacity: 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
391
508
|
/* THE TRAIL: the page's ancestors above the title (docs/PAGE_HEADER.md, "Crumbs"). It takes
|
|
392
509
|
the row's whole first line, so the title, the tabs and the actions share the next one.
|
|
393
510
|
Ancestors are muted links, the page itself is bold, and a chevron sits between. Below
|
|
@@ -685,6 +802,20 @@ body.modal-open .studio-app-banner {
|
|
|
685
802
|
flex: 0 0 calc((100% - var(--carousel-n) * var(--carousel-gap)) / (var(--carousel-n) + var(--carousel-peek)));
|
|
686
803
|
min-width: 0;
|
|
687
804
|
scroll-snap-align: start;
|
|
805
|
+
|
|
806
|
+
/* The partial's start: past the first slide. A browser with scroll-initial-target
|
|
807
|
+
opens the row on this slide before the first paint, so the start costs no jump;
|
|
808
|
+
elsewhere the controller sets it when it connects. */
|
|
809
|
+
&[data-carousel-start-slide] {
|
|
810
|
+
scroll-initial-target: nearest;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/* The caption's line under a slide (the partial's caption:). A standard row shows it
|
|
815
|
+
under every slide; the spotlight (below) shows it under the centred slide only. */
|
|
816
|
+
@utility carousel-caption {
|
|
817
|
+
margin-top: 0.5rem;
|
|
818
|
+
@apply text-sm text-body;
|
|
688
819
|
}
|
|
689
820
|
|
|
690
821
|
@utility carousel-controls {
|
|
@@ -815,6 +946,23 @@ body.modal-open .studio-app-banner {
|
|
|
815
946
|
}
|
|
816
947
|
}
|
|
817
948
|
|
|
949
|
+
/* The pause button of an autoplaying row (autoplay:), a carousel-button beside the
|
|
950
|
+
arrows. It shows pause while the row may move and play once the reader (or reduced
|
|
951
|
+
motion) has stopped it; the controller sets data-carousel-stopped and the name. */
|
|
952
|
+
@utility carousel-autoplay {
|
|
953
|
+
& .carousel-autoplay-play {
|
|
954
|
+
display: none;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
&[data-carousel-stopped] .carousel-autoplay-pause {
|
|
958
|
+
display: none;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
&[data-carousel-stopped] .carousel-autoplay-play {
|
|
962
|
+
display: block;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
818
966
|
/* -- The spotlight carousel (studio/_carousel, style: :spotlight) --------------
|
|
819
967
|
The same region, track, arrows, dots and keys, but each slide snaps to the centre.
|
|
820
968
|
The centred slide is full size and full colour, with its caption under it; its
|
|
@@ -1023,6 +1171,13 @@ body.modal-open .studio-app-banner {
|
|
|
1023
1171
|
scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) + var(--section-header-h));
|
|
1024
1172
|
}
|
|
1025
1173
|
|
|
1174
|
+
/* An in-page link's target takes focus when the jump lands (studio/in_page_links). One that
|
|
1175
|
+
is not focusable of itself gets tabindex=-1 and this mark until it loses focus. Focus is
|
|
1176
|
+
for the next Tab, not a ring round a whole section. */
|
|
1177
|
+
[data-in-page-focus]:focus {
|
|
1178
|
+
outline: none;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1026
1181
|
/* -- Scrims: tints and shades ----------------------------------------------
|
|
1027
1182
|
A scrim is the wash laid over media (a photo, a video) so text reads on it.
|
|
1028
1183
|
It comes in two TONES, and the tone is a design decision, not a colour pick:
|
|
@@ -2279,13 +2434,14 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2279
2434
|
stop. The violet, centered on the brand colour, is bright enough at its low stop. */
|
|
2280
2435
|
html.dark[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) { --lit-glow-stop: var(--lit-top-stop); }
|
|
2281
2436
|
/* A TOUCH OF TRANSPARENCY on a full-size filled button, dark theme only: a photo behind it
|
|
2282
|
-
shows
|
|
2437
|
+
shows through. At 0.65 (Alex McRitchie, 2026-11-05) white text on the primary measures 5.6:1
|
|
2438
|
+
on a dark page, 3.4:1 over a mid-grey photo and 3.0:1 over a light-mid one (#a0a0a0). An outer shadow is not painted
|
|
2283
2439
|
under its own box, so the glow never brightens the fill. On a light page the backdrop would
|
|
2284
2440
|
lift the fill and cost contrast, so it stays solid there. Without a blur, or without relative
|
|
2285
2441
|
oklch (the gradient would drop out and leave white text on nothing), it stays solid. */
|
|
2286
2442
|
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (color: oklch(from red l c h)) {
|
|
2287
2443
|
html.dark[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"], [class*=" bg-"], [class^="bg-"]) {
|
|
2288
|
-
--lit-fill-alpha: 0.
|
|
2444
|
+
--lit-fill-alpha: 0.65;
|
|
2289
2445
|
background-color: transparent;
|
|
2290
2446
|
-webkit-backdrop-filter: blur(6px);
|
|
2291
2447
|
backdrop-filter: blur(6px);
|
|
@@ -19,6 +19,7 @@ import IdentityMiniController from "studio/controllers/identity_mini_controller"
|
|
|
19
19
|
import { startPinnedStack } from "studio/pinned_stack"
|
|
20
20
|
import { installAlpineShims } from "studio/alpine_shims"
|
|
21
21
|
import { installStudioLoader } from "studio/loader"
|
|
22
|
+
import "studio/in_page_links"
|
|
22
23
|
|
|
23
24
|
installAlpineShims()
|
|
24
25
|
startPinnedStack()
|
|
@@ -48,8 +48,15 @@ export function perPage(clientWidth, slideWidth, gap) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// The stop an arrow or a key takes the row to, held to the first and last stop.
|
|
51
|
-
|
|
51
|
+
// A LOOPING row (loop: true) wraps instead: Next at the last stop goes to the
|
|
52
|
+
// first, Previous at the first goes to the last. A page that would overshoot an
|
|
53
|
+
// end still lands on that end first, so no slide is skipped on the way round.
|
|
54
|
+
export function targetStop(action, current, page, count, loop = false) {
|
|
52
55
|
const last = Math.max(0, count - 1)
|
|
56
|
+
if (loop && last > 0) {
|
|
57
|
+
if (action === "next" && current >= last) return 0
|
|
58
|
+
if (action === "prev" && current <= 0) return last
|
|
59
|
+
}
|
|
53
60
|
const to = {
|
|
54
61
|
prev: current - page,
|
|
55
62
|
next: current + page,
|
|
@@ -60,6 +67,64 @@ export function targetStop(action, current, page, count) {
|
|
|
60
67
|
return Math.min(last, Math.max(0, to))
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
// AUTOPLAY (the partial's autoplay:, data-carousel-autoplay): the row moves one
|
|
71
|
+
// stop each interval, never while a reason holds it still.
|
|
72
|
+
|
|
73
|
+
// The interval in seconds from the attribute, or null for no autoplay.
|
|
74
|
+
export function autoplaySeconds(value) {
|
|
75
|
+
if (value === null || value === undefined || value === "") return null
|
|
76
|
+
const seconds = Number(value)
|
|
77
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The stop autoplay moves to from `current`: the next one, round to the first
|
|
81
|
+
// on a looping row. Null where it has nowhere to go: the last stop of a row that
|
|
82
|
+
// does not loop (autoplay stops there), or a row with one stop.
|
|
83
|
+
export function autoplayStop(current, count, loop = false) {
|
|
84
|
+
if (!(count > 1)) return null
|
|
85
|
+
if (current < count - 1) return current + 1
|
|
86
|
+
return loop ? 0 : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// What holds autoplay still. "stopped" is the reader's own pause button (and
|
|
90
|
+
// reduced motion, which starts the row stopped); it alone shows the play icon.
|
|
91
|
+
// The rest lift on their own: the pointer leaves, focus leaves, the tab shows,
|
|
92
|
+
// the row scrolls back into view, the reader stops touching it.
|
|
93
|
+
export const PAUSE_REASONS = ["stopped", "hover", "focus", "hidden", "offscreen", "interaction"]
|
|
94
|
+
|
|
95
|
+
// The reasons with one set or lifted. A new Set: the old one is left alone.
|
|
96
|
+
export function withReason(reasons, reason, on) {
|
|
97
|
+
const next = new Set(reasons)
|
|
98
|
+
if (on) next.add(reason)
|
|
99
|
+
else next.delete(reason)
|
|
100
|
+
return next
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The reasons a row starts with: stopped under reduced motion, hidden in a
|
|
104
|
+
// hidden tab.
|
|
105
|
+
export function initialReasons({ reducedMotion = false, hidden = false } = {}) {
|
|
106
|
+
const reasons = new Set()
|
|
107
|
+
if (reducedMotion) reasons.add("stopped")
|
|
108
|
+
if (hidden) reasons.add("hidden")
|
|
109
|
+
return reasons
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Whether autoplay runs: it is on and nothing holds it.
|
|
113
|
+
export function autoplayRunning(seconds, reasons) {
|
|
114
|
+
return seconds !== null && seconds > 0 && reasons.size === 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// How long after the reader's last swipe, scroll or press autoplay waits to
|
|
118
|
+
// resume: one interval, and never under five seconds.
|
|
119
|
+
export function idleDelay(seconds) {
|
|
120
|
+
return Math.max(5000, (seconds || 0) * 1000)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The pause button's name, from whether the reader has stopped the row.
|
|
124
|
+
export function toggleLabel(stopped) {
|
|
125
|
+
return stopped ? "Play slideshow" : "Pause slideshow"
|
|
126
|
+
}
|
|
127
|
+
|
|
63
128
|
// Keys inside these belong to the element, not the row: a form field, and a widget
|
|
64
129
|
// that moves its own selection with the arrow keys.
|
|
65
130
|
export const KEY_OWNERS = [
|
|
@@ -97,3 +162,11 @@ export function centerStops(slides, clientWidth, max) {
|
|
|
97
162
|
if (slides.length === 0) return [0]
|
|
98
163
|
return slides.map(({ left, width }) => Math.min(max, Math.max(0, left + width / 2 - clientWidth / 2)))
|
|
99
164
|
}
|
|
165
|
+
|
|
166
|
+
// The stop a row opens on, from the partial's start index: a standard row with
|
|
167
|
+
// more slides than stops opens at its last stop.
|
|
168
|
+
export function startStop(start, count) {
|
|
169
|
+
if (!(count > 0)) return 0
|
|
170
|
+
const index = Math.floor(Number(start)) || 0
|
|
171
|
+
return Math.min(count - 1, Math.max(0, index))
|
|
172
|
+
}
|