studio-engine 0.140.0 → 0.141.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 607b184e22c3660009dfc7e342922ca3bdae6b13a7ca7ed32dc618957dfc819a
4
- data.tar.gz: 26a6741ca01d65b37d6f16a663f35c6d253f87254af05fd41836d23e2a5ce48b
3
+ metadata.gz: fdbbb3a4ed38791b34c2acda907501f647b1574dd58c3ae462ebb75a480f20ac
4
+ data.tar.gz: cd7e60599fd6f9c56896532aa50ab3af7e2032ea0b5b947419f3cfd9609fb642
5
5
  SHA512:
6
- metadata.gz: 1f6a8450e4862aea8e8f80c214353442eafcf0c56bbcd0d78848dd291fab92ebc95ed41b836b01ba11a6601a66a652cf06153ef97f7e7c4f31910603457572d1
7
- data.tar.gz: 24cdd966d0cad48ba9efb03fe6d54c2ec801051c7ccf23f104411fe3a4cf4eac7ea7155b453fb308ba5625be4bd30d7945c83bc2d822026ae4f6b3de194b2f76
6
+ metadata.gz: 6c4cd34992a2181995c6e111ea61eeef896c210e277a0d6a96ee9842d710af16e401698ac8935684f98ff02a76b100e3be2c48c1f052ec4ee1e2bde2ff405335
7
+ data.tar.gz: 8bb445755ac725f8455454499ab6987428898e76db15214da7ef930338054954f85b9f0803fe2c3cf3416c592928087cecbe2b994525231265be633acf06bdb1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.141.0 — 2026-11-09
8
+
9
+ ### Changed
10
+
11
+ - **The shared reveal waits need a selector.** `revealed`, `settled` and `arrivalSettled` in `e2e_helpers.js` now refuse a call with no selector. Right after a click the old page is still the document and has revealed too, so a wait with nothing of the new page's to find first read the old page and returned at once. Pass a selector that only the new page has.
12
+
13
+ ### Fixed
14
+
15
+ - **A caption stage opened on a `#fragment` shows the open slide's caption.** Both carousel controllers load lazily, so a row could open on the fragment's slide and announce it before `carousel-captions` listened, leaving the first slide's caption up. The captions controller now reads the row's open slide (`data-current`, a looping row's copies not counted: `studio/carousel` `markedSlide`) when it connects, then follows `studio:carousel:change`. Nothing to change in a host.
16
+ - **A caption wrapper follows only its own row.** A change event from a second carousel inside the wrapper moved its captions. The captions now follow the row marked `data-carousel-captions-target="carousel"`, else the first carousel inside the wrapper; the README says one row per caption wrapper.
17
+ - **A narrow cascade no longer cuts the open card's shadow.** The narrow region's edge mask paints only inside the track's box, so a `shadow-xl` under the front card ended in a hard line at phone width. The track now keeps `--carousel-cascade-shadow-room` (2.5rem) under the cards and gives it back with a negative margin, so nothing moves, and the controls over that room keep their presses. A deeper shadow raises the variable.
18
+ - **The image-only pattern respects reduced motion.** The README pattern and the style guide specimen carry `motion-reduce:transition-none` on each caption and on the card ring; copy it into a host's markup.
19
+
7
20
  ## 0.140.0 — 2026-11-09
8
21
 
9
22
  ### Fixed
data/README.md CHANGED
@@ -458,13 +458,13 @@ grid cell. The engine's `carousel-captions` controller (lazy) marks the open one
458
458
  locals: { label: "Portfolio", items: @apps, style: :cascade, loop: true, dots: false,
459
459
  slide_label: ->(app) { app.name } } do |app| %>
460
460
  <%= link_to app.url, aria: { label: app.name },
461
- class: "block aspect-[4/3] overflow-hidden rounded-2xl transition-shadow duration-300 in-data-current:ring-2 in-data-current:ring-primary" do %>
461
+ class: "block aspect-[4/3] overflow-hidden rounded-2xl transition-shadow duration-300 motion-reduce:transition-none in-data-current:ring-2 in-data-current:ring-primary" do %>
462
462
  <%= image_tag app.image, alt: "", class: "h-full w-full object-cover" %>
463
463
  <% end %>
464
464
  <% end %>
465
465
  <div class="grid pt-4 text-center">
466
466
  <% @apps.each_with_index do |app, index| %>
467
- <div class="col-start-1 row-start-1 opacity-0 invisible transition-opacity data-current:opacity-100 data-current:visible"
467
+ <div class="col-start-1 row-start-1 opacity-0 invisible transition-opacity motion-reduce:transition-none data-current:opacity-100 data-current:visible"
468
468
  data-carousel-captions-target="caption"<%= " data-current".html_safe if index.zero? %><%= ' aria-hidden="true"'.html_safe unless index.zero? %>>
469
469
  <h2 class="type-h4 text-heading"><%= app.name %></h2>
470
470
  <p class="text-sm text-secondary"><%= app.blurb %></p>
@@ -475,11 +475,28 @@ grid cell. The engine's `carousel-captions` controller (lazy) marks the open one
475
475
  ```
476
476
 
477
477
  The server marks the caption of the slide the row opens on (`start:`; index 0 for a
478
- looping row). The ring keys on `data-current`, which the cascade and the spotlight set on
479
- the open slide (`in-data-current:` matches the card inside it). The row's status line
478
+ looping row). Both controllers load lazily, so a row that opens elsewhere (a `#fragment`)
479
+ may announce it before the captions connect: `carousel-captions` reads the row's open slide
480
+ (`data-current`) when it connects, then follows the change event. The ring keys on
481
+ `data-current`, which the cascade and the spotlight set on the open slide
482
+ (`in-data-current:` matches the card inside it). The row's status line
480
483
  already says which slide is open, so the caption stack is not a live region. A host that
481
484
  needs more than a caption listens for `studio:carousel:change` itself.
482
485
 
486
+ **One row per caption wrapper.** The captions follow the wrapper's own row: the region
487
+ marked `data-carousel-captions-target="carousel"` (pass `data: { carousel_captions_target:
488
+ "carousel" }` to the partial), else the first carousel inside the wrapper. A change event
489
+ from any other row inside it moves nothing. Keep `motion-reduce:transition-none` on the
490
+ caption fade and the card's ring: under reduced motion both swap at once, as the stack
491
+ does.
492
+
493
+ **A shadow under a narrow stack.** A narrow region's cascade fades its side cards at the
494
+ track's edge with a mask, and a mask paints only inside the track's box. The track keeps
495
+ `--carousel-cascade-shadow-room` (2.5rem, Tailwind's `shadow-xl`) of room under the cards
496
+ for the open card's shadow and gives it back with a negative margin, so nothing moves. A
497
+ deeper shadow (`shadow-2xl`) raises it: `[--carousel-cascade-shadow-room:4rem]` on the
498
+ partial's `class_name:`.
499
+
483
500
  ### The "at" time stamp — `at_time_tag`
484
501
 
485
502
  Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
@@ -1175,16 +1175,37 @@ body.modal-open .studio-app-banner {
1175
1175
  }
1176
1176
 
1177
1177
  /* A narrow region shows one each side, on wider cards. Those side cards run past
1178
- the track's edge, so the edge fades them out instead of cutting them hard. */
1178
+ the track's edge, so the edge fades them out instead of cutting them hard.
1179
+
1180
+ The mask fades only the sides, but it paints only inside the track's own box: a
1181
+ card's outer shadow below the box would end in a hard line. So the box takes room
1182
+ under the cards for the shadow (--carousel-cascade-shadow-room, 2.5rem: Tailwind's
1183
+ shadow-xl; raise it for a deeper shadow) and gives it back with a matching negative
1184
+ margin, so nothing below moves. The controls, which now sit over that room, are
1185
+ lifted above the track (its perspective makes it a stacking context) so they keep
1186
+ every press. */
1179
1187
  @container (width < 36rem) {
1180
1188
  & > .carousel-track {
1181
1189
  --carousel-cascade-reach: 1;
1182
1190
  --cascade-w: 64cqi;
1183
1191
  --cascade-shift: 0.44;
1184
1192
  --cascade-depth: 3.5rem;
1193
+ padding-block-end: calc(0.75rem + var(--carousel-cascade-shadow-room, 2.5rem));
1194
+ margin-block-end: calc(-1 * var(--carousel-cascade-shadow-room, 2.5rem));
1185
1195
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--carousel-cascade-fade, 10%), #000 calc(100% - var(--carousel-cascade-fade, 10%)), transparent);
1186
1196
  mask-image: linear-gradient(to right, transparent, #000 var(--carousel-cascade-fade, 10%), #000 calc(100% - var(--carousel-cascade-fade, 10%)), transparent);
1187
1197
  }
1198
+
1199
+ & > .carousel-controls {
1200
+ position: relative;
1201
+ z-index: 1;
1202
+ }
1203
+
1204
+ /* A row with no controls (one slide, or a failed controller) keeps the room: with
1205
+ nothing over it, the track's box would cover what follows and take its presses. */
1206
+ &:is([data-carousel-static], [data-studio-controller-failed]) > .carousel-track {
1207
+ margin-block-end: 0;
1208
+ }
1188
1209
  }
1189
1210
 
1190
1211
  &[data-carousel-dragging] > .carousel-track {
@@ -344,6 +344,16 @@ export function changeDetail(previous, index, slides) {
344
344
  return { index, previous: Number.isInteger(previous) ? previous : null, slide: list[index], total: list.length }
345
345
  }
346
346
 
347
+ // The open slide's index among a region's real slides: the first that carries
348
+ // data-current, a looping row's copies (data-carousel-clone) not counted. -1 when none
349
+ // is marked (a standard row marks none). A host that connects after the row opened
350
+ // (a #fragment, a lazy controller) reads where it stands with it, then follows
351
+ // CHANGE_EVENT.
352
+ export function markedSlide(slides) {
353
+ const real = Array.from(slides || []).filter((slide) => !slide.hasAttribute("data-carousel-clone"))
354
+ return real.findIndex((slide) => slide.hasAttribute("data-current"))
355
+ }
356
+
347
357
  // -- the cascade's press --------------------------------------------------------
348
358
 
349
359
  // The slide a click on a cascade belongs to. A mouse press on a side slide records
@@ -2,7 +2,8 @@
2
2
  // image-only pattern, README "The carousel"). A caption inside each slide would ride
3
3
  // a cascade's 3D move, so the captions live outside the row: one per item, in item
4
4
  // order, laid in one grid cell, and this marks the open slide's caption data-current
5
- // and hides the rest from assistive tech (aria-hidden). The page styles the cross-fade.
5
+ // and hides the rest from assistive tech (aria-hidden). The page styles the cross-fade
6
+ // (with motion-reduce:transition-none, so reduced motion swaps it at once).
6
7
  //
7
8
  // <div data-studio-controller="carousel-captions">
8
9
  // <%= render layout: "studio/carousel", ... %>
@@ -10,26 +11,44 @@
10
11
  // <div data-carousel-captions-target="caption" aria-hidden="true">second</div>
11
12
  // </div>
12
13
  //
14
+ // ONE ROW PER WRAPPER. It follows its own row: the region marked
15
+ // data-carousel-captions-target="carousel" (pass it as the partial's data:), else the
16
+ // first carousel region inside it. A change event from any other row inside the
17
+ // wrapper moves nothing.
18
+ //
13
19
  // It listens for the carousel's change event (studio/carousel CHANGE_EVENT), which
14
- // bubbles from the region inside it, so it needs no observer and no timer. The server
15
- // marks the caption of the slide the row opens on. A lazy controller (studio/stimulus):
16
- // a page with no caption stage fetches nothing; one whose controller failed keeps the
17
- // server's caption.
20
+ // bubbles from the region inside it, so it needs no observer and no timer. Both
21
+ // controllers load lazily (studio/stimulus), so the row may open somewhere else (a
22
+ // #fragment) and announce it before this connects: connect() reads the row's open
23
+ // slide (studio/carousel markedSlide) first, then follows the event. The server marks
24
+ // the caption of the slide the row opens on, so a page with no caption stage fetches
25
+ // nothing, and one whose controller failed keeps the server's caption.
18
26
  import { Controller } from "@hotwired/stimulus"
19
- import { CHANGE_EVENT } from "studio/carousel"
27
+ import { CHANGE_EVENT, markedSlide } from "studio/carousel"
28
+
29
+ const REGION = ".carousel[data-studio-controller~='carousel']"
20
30
 
21
31
  export default class extends Controller {
22
- static targets = ["caption"]
32
+ static targets = ["caption", "carousel"]
23
33
 
24
34
  connect() {
25
- this.follow = (event) => this.show(event.detail?.index)
35
+ this.follow = (event) => {
36
+ if (event.target === this.region()) this.show(event.detail?.index)
37
+ }
26
38
  this.element.addEventListener(CHANGE_EVENT, this.follow)
39
+ const region = this.region()
40
+ if (region) this.show(markedSlide(region.querySelectorAll(":scope > .carousel-track > .carousel-slide")))
27
41
  }
28
42
 
29
43
  disconnect() {
30
44
  this.element.removeEventListener(CHANGE_EVENT, this.follow)
31
45
  }
32
46
 
47
+ // The row these captions follow: the carousel target, else the first row inside.
48
+ region() {
49
+ return this.hasCarouselTarget ? this.carouselTarget : this.element.querySelector(REGION)
50
+ }
51
+
33
52
  show(index) {
34
53
  if (!Number.isInteger(index) || index < 0 || index >= this.captionTargets.length) return
35
54
  this.captionTargets.forEach((caption, at) => {
@@ -291,12 +291,12 @@
291
291
  locals: { label: "Image-only specimen", items: (1..5).to_a, style: :cascade, loop: true, dots: false,
292
292
  id: "style-carousel-image-only-specimen", slide_label: ->(n) { "Picture #{n}" } } do |n| %>
293
293
  <div role="img" aria-label="Picture <%= n %>"
294
- class="aspect-[4/3] overflow-hidden rounded-2xl transition-shadow duration-300 in-data-current:ring-2 in-data-current:ring-primary"
294
+ class="aspect-[4/3] overflow-hidden rounded-2xl transition-shadow duration-300 motion-reduce:transition-none in-data-current:ring-2 in-data-current:ring-primary"
295
295
  style="background-image: linear-gradient(<%= 120 + n * 30 %>deg, rgb(var(--color-primary-rgb) / <%= 0.25 + n * 0.12 %>), rgb(var(--color-primary-rgb) / 0.08))"></div>
296
296
  <% end %>
297
297
  <div class="grid pt-3 text-center">
298
298
  <% (1..5).each do |n| %>
299
- <div class="col-start-1 row-start-1 opacity-0 invisible transition-opacity duration-200 data-current:opacity-100 data-current:visible"
299
+ <div class="col-start-1 row-start-1 opacity-0 invisible transition-opacity duration-200 motion-reduce:transition-none data-current:opacity-100 data-current:visible"
300
300
  data-carousel-captions-target="caption"<%= " data-current".html_safe if n == 1 %><%= ' aria-hidden="true"'.html_safe unless n == 1 %>>
301
301
  <p class="text-sm font-semibold text-heading">Picture <%= n %></p>
302
302
  <p class="text-xs text-secondary">One caption under the stack.</p>
@@ -11,14 +11,16 @@ host gets it with the engine it already bundles: no npm registry, no token.
11
11
  - `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`; for a page
12
12
  transition spec, the reveal waits (`watchReveal`, `revealed`, `settled`,
13
13
  `arrivalSettled`, `frames`) and the log-on-red hook (`sessionLog`, `logOnRed`).
14
- The waits read events the browser fires, never a clock.
14
+ The waits read events the browser fires, never a clock. Each wait takes a
15
+ selector that only the new page has, and refuses without one: right after a
16
+ click, the old page has revealed too.
17
+ - `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
18
+ import a module by the bare name the importmap pins it under.
15
19
 
16
20
  A local run reuses a server already on the e2e port only when that server's
17
21
  working directory is inside this tree (`mayReuseServer` in `playwright_base.js`,
18
22
  read with `lsof`). Any other server there is refused, with a warning that names
19
23
  its root: give the run its own port with `E2E_PORT=<free port>`.
20
- - `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
21
- import a module by the bare name the importmap pins it under.
22
24
 
23
25
  Every file here requires only `node:` builtins and its own siblings, because it
24
26
  is loaded from the gem's directory, which has no `node_modules`.
@@ -100,8 +100,13 @@ function watchReveal(target) {
100
100
  // as visible, before its first render. A render-blocking sheet, or the reveal script's own
101
101
  // hold to the end of parse, keeps pagereveal back, and until then no mark is set and no
102
102
  // pseudo runs (engine CI run 42503970890 read an empty log at 312 ms).
103
+ //
104
+ // THE SELECTOR IS REQUIRED, and it must name something only the new page has. Right after a
105
+ // click the old page is still the document, and its own reveal has already happened, so a wait
106
+ // with nothing to find first would read the old page and return at once.
103
107
  async function revealed(page, selector) {
104
- if (selector) await page.locator(selector).first().waitFor({ state: "visible" });
108
+ if (!selector) throw new TypeError("revealed() needs a selector only the new page has: the old page has revealed too");
109
+ await page.locator(selector).first().waitFor({ state: "visible" });
105
110
  await page.waitForFunction(() => window.__studioReveal && window.__studioReveal.revealed === true);
106
111
  }
107
112
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.140.0"
2
+ VERSION = "0.141.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.140.0
4
+ version: 0.141.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie