studio-engine 0.132.0 → 0.133.1

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 +88 -1
  3. data/README.md +61 -29
  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 +10 -4
  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: 4fb5298612e52f07d4d829bf6154de3fe55d44e79f9fcc7fb3fc5c64a5fc77b1
4
+ data.tar.gz: ae606171f7f2f8866e4bdf20b609192ab6da90a502357c32267f00810a1567ee
5
5
  SHA512:
6
- metadata.gz: fb8cc964841b4eb76cdb6a64a69cf2394f18d7d87863585c1159c2afc12305e5268acb8c508f7922744669793be9364135e79898415a1d6444c57c1e64dbd815
7
- data.tar.gz: 3ad57bddb03aaf79c98c1f6f38ac5e92d743f59ce7701e502c092123b8731f0ac1c1d32a209310684662c8c81c6f2a2b44cbdb0a45c29d9265013aaa782ef607
6
+ metadata.gz: 7aba4b035be637a6496b1c7e05d6bf9829a803f1566bd8192a3de247ae692e678d4387b6c8af861b1b56e763d79033c4411ab4385896bd4ba482a6eeb9ce166e
7
+ data.tar.gz: 17c221456698d97e663896658e84d22e174bf889d3ee684dc79ea9f7925cda3c8ac1a6b5212dafc427abdbbfaeafe1a2db101f5eebddf5f612bb65d5f14b89fc
data/CHANGELOG.md CHANGED
@@ -1,9 +1,96 @@
1
1
  # Changelog
2
2
 
3
- The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package and pin the floor each one needs — the pins differ on purpose, and every consumer records why beside its own. Bumping the gem version and updating consumer lockfiles is a release; `bin/release prepare` allocates the version and does both (see [`docs/RELEASE.md`](./docs/RELEASE.md)). Write entries under `## Unreleased` and never write a version heading: when `prepare` allocates a version it rolls this file in the same commit, moving everything under `## Unreleased` beneath a new `## <version> — <date>` heading (written even when the bucket is empty) and leaving `## Unreleased` first and empty. It refuses the sweep, with nothing published, when the roll would lie or it cannot read this file; when it skips allocation, nothing rolls. [`docs/RELEASE.md`](./docs/RELEASE.md), *Rolling `Unreleased` into a version*, says when each happens and what to do by hand. `test/docs/changelog_structure_test.rb` guards the shape.
3
+ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package and pin the floor each one needs — the pins differ on purpose, and every consumer records why beside its own. Bumping the gem version and updating consumer lockfiles is a release; `bin/release prepare` allocates the version and does both (see [`docs/RELEASE.md`](./docs/RELEASE.md)). **Do not edit this file in a PR.** Add a fragment, `changelog.d/<slug>.<kind>.md`, instead ([`changelog.d/README.md`](./changelog.d/README.md)); `bin/changelog-check` fails a PR that adds lines under `## Unreleased`. At release, `bin/changelog-compile` folds the fragments into `## Unreleased` by kind and deletes them. Never write a version heading: when `prepare` allocates a version it rolls this file in the same commit, moving everything under `## Unreleased` beneath a new `## <version> — <date>` heading (written even when the bucket is empty) and leaving `## Unreleased` first and empty. It refuses the sweep, with nothing published, when the roll would lie or it cannot read this file; when it skips allocation, nothing rolls. [`docs/RELEASE.md`](./docs/RELEASE.md), *Rolling `Unreleased` into a version*, says when each happens and what to do by hand. `test/docs/changelog_structure_test.rb` guards the shape.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.133.1 — 2026-11-06
8
+
9
+ ### Changed
10
+
11
+ - **The browser lane runs as three shards.** It had grown to about 17 minutes of specs on
12
+ one runner, against about 9 minutes for the engine suite, so it was the long pole of
13
+ every engine PR. Each shard writes its own receipt, and `bin/e2e-executed-set-check`
14
+ now also refuses receipts whose shard total is not `shards:` in `config/e2e_lane.yml`.
15
+ Nothing changes for a consumer.
16
+
17
+ ## 0.133.0 — 2026-11-06
18
+
19
+ ### Deprecated
20
+
21
+ - **`studio/_page_header`'s `actions_html:`.** It still renders, before the menu, for one
22
+ more release. In development the first render from each call site writes one warning to
23
+ the log. Move each control to `tabs:`, `menu:`, `action:`, `tools_html:` or `meta:`.
24
+ ### Changed
25
+
26
+ - **A link to a place on the same page scrolls there. It no longer loads the page again.**
27
+ Alex McRitchie, on the style guide's tabs: "The system needs to understand when a scroll
28
+ down is the solution rather than a page change." Turbo 8 made a click on `#tricks` a full
29
+ visit: a fetch, a render and the page transition. `studio/in_page_links` now takes a plain
30
+ click on a link to this page's path and query with a `#fragment` whose element exists. It
31
+ smooth-scrolls to the element under the pinned stack (a jump under reduced motion), pushes
32
+ the fragment so Back and a shared link work, and moves focus to the target. Cross-page
33
+ links, modifier clicks, `target`, `download` and `data-turbo="false"` are untouched. The
34
+ style guide's tab highlight holds the clicked tab until the scroll settles
35
+ (docs/PAGE_HEADER.md, "In-page links"; task `in-page-links-scroll`).
36
+ - **The style guide's cards carry trick names, not markup snippets.** Alex McRitchie,
37
+ 2026-11-06: "I really just need to have a good naming convention so I can convey an
38
+ idea to my agents." Every card under Tricks, Loaders and Tasks on `/admin/style` now
39
+ shows a short name as its heading ("Sheen", "Border Glow", "Hold"), a one-line note,
40
+ and its class or helper small and muted. A variant is the name, a middle dot and a
41
+ lower-case word ("Sheen · loop", "Border Glow · clear", "Hold · recolor"), and
42
+ variants of one primitive share a card, side by side on its stage: Tricks and
43
+ Loaders went from 79 cards to 58. The usage `<pre>` and its Copy are gone; the one Copy copies the
44
+ agent line, for example `Sheen — .sheen; Sheen · loop — .sheen--loop`. One registry,
45
+ `config/studio_tricks.yml` (`Studio::Tricks`), feeds the cards and the generated
46
+ glossary `docs/TRICKS.md` (`bin/tricks-doc`; `test/lib/tricks_registry_test.rb`
47
+ fails when they drift, a name repeats, or a token names a class, helper, partial or
48
+ script the engine does not ship). `style/_specimen` takes `trick:` (and `variant:`)
49
+ in place of `klass:`, `usage:` and `note:`. The Tricks branded-loader group no longer
50
+ repeats the busy button and the overlay trigger: they are the Loaders section's Busy
51
+ Button and Overlay (task `tricks-named-not-snippets`).
52
+
53
+ ### Added
54
+
55
+ - **The page header's four kinds of control.** Alex McRitchie, 2026-11-06: the
56
+ /deployments header showed a grey pill, a purple button and a black button side by
57
+ side. `studio/_page_header` now takes the right side as four kinds, each with one look,
58
+ in one fixed order and at one height: `tabs:` (views of the same thing) or `anchors:`
59
+ (sections of this page, the same pill group), `menu:` (pages elsewhere, behind one
60
+ neutral "Links" button that opens a popover list; Escape closes it and focus returns),
61
+ `action:` (the one primary button) and `tools_html:` (local-only controls, muted). A
62
+ small primary button in the row takes a clear 1px border, so it stands at a neutral
63
+ button's height. `docs/PAGE_HEADER.md` gives the decision rule for which kind a link is,
64
+ and the style guide's Page header specimen shows all four (task
65
+ `page-header-control-kinds`).
66
+
67
+ - **A carousel can loop and play itself: `loop:` and `autoplay:`.** Alex McRitchie wants
68
+ the hub's Portfolio spotlight "auto scrolling and in a loop". `loop: true` wraps the
69
+ arrows and keys (last to first, first to last) and keeps both arrows enabled, with no
70
+ cloned slides. `autoplay: <seconds>` moves one slide each interval in `:standard` and
71
+ `:spotlight`; it holds on hover over the slides, on keyboard focus, in a hidden tab, off
72
+ screen, and after any swipe or press until the reader is idle; it starts stopped under
73
+ reduced motion and never writes the status line. A pause button (`.carousel-autoplay`,
74
+ "Pause slideshow" / "Play slideshow") meets WCAG 2.2.2. Both default off, so every
75
+ existing carousel is unchanged (task `carousel-autoplay-and-loop`).
76
+ - **The engine ships a standard sign-in card.** Alex McRitchie, on the style guide's
77
+ Email field specimen: "this should use the standard auth component with Google and
78
+ Email." `studio/auth/_sign_in_card` (a page) and `studio/auth/_sign_in_modal` (a
79
+ modal host's template) render Google, then "or", then the email magic link; Send
80
+ flips the card to a link-sent step with the shared resend footer. Locals: `heading`,
81
+ `subheading` (default "Sign in to" the app name), `return_to` (a plain in-app path
82
+ only; it rides the magic-link POST and the Google request URL), `preamble` and
83
+ `extras` slots for an age attestation or extra providers, and `demo`. Real use
84
+ posts Google to `/auth/google_oauth2` (`button_to` POST, Turbo off) and the email to
85
+ `POST /magic_link`, and reads the `503 delivery_failed` answer. `demo: true` posts
86
+ nothing. `/admin/style` > Modals > Auth is now one "Sign in" specimen that opens
87
+ the card in demo mode; the separate Email field and Resend footer specimens are
88
+ gone, and both partials stay. `studio/modals/auth/_resend_footer` takes
89
+ `close: false` for a host that is not a modal. The hub and turf-monster still
90
+ render their own cards; adoption is follow-up work. This reverses the 2026-09-09
91
+ call (PR #319) that the engine ships only the fragments, not a card. Docs: `docs/SIGN_IN_CARD.md`
92
+ (task `engine-ships-sign-in-card`).
93
+
7
94
  ## 0.132.0 — 2026-11-06
8
95
 
9
96
  ## 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.
@@ -2062,9 +2090,12 @@ in [`docs/RELEASE.md`](./docs/RELEASE.md).
2062
2090
 
2063
2091
  **Building an engine change?** Do **not** edit `lib/studio/version.rb`. The
2064
2092
  release owns the version, and McRitchie Studio's `bin/dor-check` refuses any PR
2065
- that touches that file. Update [`CHANGELOG.md`](./CHANGELOG.md) under
2066
- `Unreleased` (that is *not* gated), run `bin/release-check --build`, and open
2067
- your PR into `accepted`. That is the whole of your part.
2093
+ that touches that file. Do not edit [`CHANGELOG.md`](./CHANGELOG.md): add a
2094
+ fragment, `changelog.d/<slug>.<kind>.md` (format in
2095
+ [`changelog.d/README.md`](./changelog.d/README.md)). Engine CI runs
2096
+ `bin/changelog-check`, which fails a PR that changes what the gem ships and adds no
2097
+ fragment, or that adds lines under `## Unreleased`. Run `bin/release-check --build`
2098
+ and open your PR into `accepted`. That is the whole of your part.
2068
2099
 
2069
2100
  **Conducting the release?** Run `bin/release prepare` from mcritchie-studio and
2070
2101
  let it allocate the version — it derives the bump, commits `lib/studio/version.rb`
@@ -2072,8 +2103,9 @@ with its `Gemfile.lock` onto **`origin/release`**, then publishes, tags, and bum
2072
2103
  each consumer's lock. **Do not set the version by hand.** A hand-set number makes
2073
2104
  the allocation read the current version as already past the last tag and skip, so
2074
2105
  the hand number silently wins over the derived one, and the skip rolls no
2075
- changelog. **When it allocates, `prepare` also rolls
2076
- [`CHANGELOG.md`](./CHANGELOG.md)** in the same commit: everything under
2106
+ changelog. **When it allocates, the release compiles the fragments
2107
+ (`bin/changelog-compile`) and `prepare` rolls [`CHANGELOG.md`](./CHANGELOG.md)** in
2108
+ the same commit: everything under
2077
2109
  `## Unreleased` moves beneath the new version's heading, written even when the
2078
2110
  bucket is empty, and `## Unreleased` stays first and empty. It refuses the sweep,
2079
2111
  with nothing published, when the roll would lie or it cannot read the file.
@@ -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 = [