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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +52 -24
  4. data/app/assets/tailwind/studio_engine/engine.css +141 -0
  5. data/app/javascript/studio/application.js +1 -0
  6. data/app/javascript/studio/carousel.js +66 -1
  7. data/app/javascript/studio/controllers/carousel_controller.js +139 -8
  8. data/app/javascript/studio/controllers/page_header_controller.js +64 -1
  9. data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
  10. data/app/javascript/studio/in_page_links.js +308 -0
  11. data/app/javascript/studio/page_header.js +17 -0
  12. data/app/views/studio/_carousel.html.erb +33 -5
  13. data/app/views/studio/_page_header.html.erb +64 -12
  14. data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
  15. data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
  16. data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
  17. data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
  18. data/app/views/style/_glass.html.erb +4 -8
  19. data/app/views/style/_loaders.html.erb +11 -35
  20. data/app/views/style/_modals.html.erb +29 -39
  21. data/app/views/style/_scrims.html.erb +10 -20
  22. data/app/views/style/_site_footer.html.erb +10 -18
  23. data/app/views/style/_specimen.html.erb +13 -41
  24. data/app/views/style/_tasks.html.erb +4 -22
  25. data/app/views/style/_trick_label.html.erb +33 -0
  26. data/app/views/style/_tricks.html.erb +127 -207
  27. data/app/views/style/index.html.erb +2 -2
  28. data/app/views/style/modals/_wallet_connect.html.erb +2 -1
  29. data/config/studio_tricks.yml +470 -0
  30. data/lib/studio/page_header.rb +53 -0
  31. data/lib/studio/tricks.rb +110 -0
  32. data/lib/studio/version.rb +1 -1
  33. data/lib/studio.rb +6 -4
  34. metadata +9 -3
  35. data/app/views/style/modals/_ds_email_field.html.erb +0 -58
  36. 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: dd2c739840540a736a0ad347abe13b22047ff20070c8e336df86172c6518d848
4
- data.tar.gz: fced70c6c90490c2d74732bdae8270b25f0e7717a87623daf734984c6e4fb101
3
+ metadata.gz: 7aa30f915a5a06beddbc97ad855340b80114d9875dab7d43669596202a62f197
4
+ data.tar.gz: 5737966bb7427575a76320d618e5a1221b747625c6e8393a4f2a4ede7ed4a306
5
5
  SHA512:
6
- metadata.gz: fb8cc964841b4eb76cdb6a64a69cf2394f18d7d87863585c1159c2afc12305e5268acb8c508f7922744669793be9364135e79898415a1d6444c57c1e64dbd815
7
- data.tar.gz: 3ad57bddb03aaf79c98c1f6f38ac5e92d743f59ce7701e502c092123b8731f0ac1c1d32a209310684662c8c81c6f2a2b44cbdb0a45c29d9265013aaa782ef607
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, 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
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
- actions_html: link_to("New Task", new_task_path, class: "btn btn-sm btn-primary"),
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 no sign-in card.** Each app owns its own and computes its
625
- credential buttons itself: turf-monster's is `app/views/modals/_auth.html.erb`,
626
- which is also the authority `lib/studio.rb` cites beside the Solana routes. The
627
- engine's one auth card was the living style guide's MIRROR of turf's,
628
- `style/modals/_auth`, and it was retired with the other mirrors on 2026-09-09
629
- (PR #319). For sign-in the engine now ships fragments, not a card:
630
- `studio/modals/shared/_email_field` and `studio/modals/auth/_resend_footer`,
631
- each carded on the style guide.
632
-
633
- **The wallet credential slot has no host today.** solana-studio still ships
634
- `solana_studio/auth/_wallet_credential`, a Solana button written to be
635
- *contributed* into a sign-in card rather than copied into one. The retired
636
- mirror was its only renderer. Neither turf-monster nor mcritchie-studio names
637
- the path; turf's card draws its wallet button directly. So bundling solana-studio
638
- no longer puts a wallet button anywhere. Whether the engine should host the slot
639
- again, or the convention should be retired, is an open call, not a fact this
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. An app grows a **fifth section, its
835
- own**, by defining one partial:
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
- export function targetStop(action, current, page, count) {
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 = [