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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +21 -4
- data/app/assets/tailwind/studio_engine/engine.css +22 -1
- data/app/javascript/studio/carousel.js +10 -0
- data/app/javascript/studio/controllers/carousel_captions_controller.js +27 -8
- data/app/views/style/_tricks.html.erb +2 -2
- data/lib/studio/node_tooling/README.md +5 -3
- data/lib/studio/node_tooling/e2e_helpers.js +6 -1
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fdbbb3a4ed38791b34c2acda907501f647b1574dd58c3ae462ebb75a480f20ac
|
|
4
|
+
data.tar.gz: cd7e60599fd6f9c56896532aa50ab3af7e2032ea0b5b947419f3cfd9609fb642
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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).
|
|
479
|
-
the
|
|
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.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// server
|
|
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) =>
|
|
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)
|
|
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
|
|
data/lib/studio/version.rb
CHANGED