studio-engine 0.132.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 +77 -0
- data/README.md +52 -24
- data/app/assets/tailwind/studio_engine/engine.css +141 -0
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/carousel.js +66 -1
- data/app/javascript/studio/controllers/carousel_controller.js +139 -8
- 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 +33 -5
- 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,6 +4,83 @@ 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
|
+
|
|
7
84
|
## 0.132.0 — 2026-11-06
|
|
8
85
|
|
|
9
86
|
## 0.131.0 — 2026-11-05
|
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,
|
|
@@ -343,6 +349,26 @@ row opens on its first. The browser sets it before the first paint where it has
|
|
|
343
349
|
`scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
|
|
344
350
|
glide and nothing announced. A `#fragment` link to an element inside the row wins.
|
|
345
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
|
+
```
|
|
371
|
+
|
|
346
372
|
```erb
|
|
347
373
|
<%= render layout: "studio/carousel",
|
|
348
374
|
locals: { label: "Team", items: @people, style: :spotlight, caption: ->(person) { person.title } } do |person| %>
|
|
@@ -621,23 +647,22 @@ inline transport nothing listens, so the dispatch is a harmless no-op there.
|
|
|
621
647
|
|
|
622
648
|
#### The auth modal's credential slot
|
|
623
649
|
|
|
624
|
-
**This engine ships
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
(PR #319)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
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.
|
|
641
666
|
|
|
642
667
|
**A card that does host it owns the gate.** The retired mirror's rules still
|
|
643
668
|
hold; they simply live in that app's view now. Two layers answer two different
|
|
@@ -831,8 +856,11 @@ than assuming drop-in equivalence:
|
|
|
831
856
|
### Style guide — your app's own section
|
|
832
857
|
|
|
833
858
|
`/admin/style` is the engine's living style guide, and every section on it is
|
|
834
|
-
the engine's: Theme, Modals, Tricks, Tasks.
|
|
835
|
-
|
|
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:
|
|
836
864
|
|
|
837
865
|
A host may also define `app/views/style/host/_logos.html.erb`, which replaces the Theme section's
|
|
838
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
|
|
@@ -829,6 +946,23 @@ body.modal-open .studio-app-banner {
|
|
|
829
946
|
}
|
|
830
947
|
}
|
|
831
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
|
+
|
|
832
966
|
/* -- The spotlight carousel (studio/_carousel, style: :spotlight) --------------
|
|
833
967
|
The same region, track, arrows, dots and keys, but each slide snaps to the centre.
|
|
834
968
|
The centred slide is full size and full colour, with its caption under it; its
|
|
@@ -1037,6 +1171,13 @@ body.modal-open .studio-app-banner {
|
|
|
1037
1171
|
scroll-margin-top: calc(var(--nav-h, 5rem) + var(--pin-page-h, 0px) + var(--section-header-h));
|
|
1038
1172
|
}
|
|
1039
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
|
+
|
|
1040
1181
|
/* -- Scrims: tints and shades ----------------------------------------------
|
|
1041
1182
|
A scrim is the wash laid over media (a photo, a video) so text reads on it.
|
|
1042
1183
|
It comes in two TONES, and the tone is a design decision, not a colour pick:
|
|
@@ -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 = [
|