studio-engine 0.131.0 → 0.132.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 +19 -1
- data/README.md +13 -4
- data/app/assets/tailwind/studio_engine/engine.css +17 -2
- data/app/javascript/studio/carousel.js +8 -0
- data/app/javascript/studio/controllers/carousel_controller.js +25 -2
- data/app/views/studio/_carousel.html.erb +25 -10
- data/app/views/style/_tricks.html.erb +1 -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: dd2c739840540a736a0ad347abe13b22047ff20070c8e336df86172c6518d848
|
|
4
|
+
data.tar.gz: fced70c6c90490c2d74732bdae8270b25f0e7717a87623daf734984c6e4fb101
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb8cc964841b4eb76cdb6a64a69cf2394f18d7d87863585c1159c2afc12305e5268acb8c508f7922744669793be9364135e79898415a1d6444c57c1e64dbd815
|
|
7
|
+
data.tar.gz: 3ad57bddb03aaf79c98c1f6f38ac5e92d743f59ce7701e502c092123b8731f0ac1c1d32a209310684662c8c81c6f2a2b44cbdb0a45c29d9265013aaa782ef607
|
data/CHANGELOG.md
CHANGED
|
@@ -4,10 +4,20 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.132.0 — 2026-11-06
|
|
8
|
+
|
|
7
9
|
## 0.131.0 — 2026-11-05
|
|
8
10
|
|
|
9
11
|
### Added
|
|
10
12
|
|
|
13
|
+
- **A carousel opens where you tell it: `start:`, and a spotlight opens on its middle
|
|
14
|
+
slide.** Alex McRitchie saw an empty left half on a spotlight's first slide. `start:`
|
|
15
|
+
takes an index or `:middle`; it defaults to `:middle` for `style: :spotlight` and to 0
|
|
16
|
+
for a standard row, so a standard row is unchanged. The slide is the browser's
|
|
17
|
+
`scroll-initial-target`, so Chromium opens there before the first paint; elsewhere the
|
|
18
|
+
controller jumps there on connect, with no glide and an empty status line. The dots,
|
|
19
|
+
`data-current` and both arrows' `aria-disabled` are right from the server. A
|
|
20
|
+
`#fragment` inside the row wins over the start (task `carousel-start-and-button-glass`).
|
|
11
21
|
- **`.studio-border-glow--clear`.** The border glow on a clear button: no fill, only the
|
|
12
22
|
moving rainbow ring and its halo, with the heading ink as the label (16:1 light, 18:1 dark,
|
|
13
23
|
measured). `<button class="btn studio-border-glow studio-border-glow--clear">`. Under
|
|
@@ -36,7 +46,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
36
46
|
same carousel, arrows, dots and keys, but each slide snaps to the centre. The centred
|
|
37
47
|
slide is full size and colour, with an optional `caption:` line under it (a callable on
|
|
38
48
|
the item); its neighbours are smaller (0.75), faded and grey, and the next ones peek at
|
|
39
|
-
the edges. An arrow or key moves one slide, and every
|
|
49
|
+
the edges. An arrow ("Previous slide", "Next slide") or key moves one slide, and every
|
|
50
|
+
slide gets a dot. It opens on its middle slide (`start:`, below). Where the browser
|
|
40
51
|
has scroll-driven animations (`animation-timeline: view()`), each slide's own view
|
|
41
52
|
timeline drives the look, so it follows the finger; elsewhere the controller marks the
|
|
42
53
|
centred slide `data-current` and CSS transitions it. Reduced motion scales and greys
|
|
@@ -84,6 +95,11 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
84
95
|
|
|
85
96
|
### Changed
|
|
86
97
|
|
|
98
|
+
- **A dark, full-size lit button's fill drops from 0.85 to 0.65 alpha** (`--lit-fill-alpha`),
|
|
99
|
+
Alex McRitchie's "even more transparent". White label on the primary, measured from painted
|
|
100
|
+
pixels at the label's median: a dark page 3.95 to 5.62, the lab photo 3.16 to 3.29, a mid-grey
|
|
101
|
+
backdrop (#808080) 3.25 to 3.42, a lighter one (#a0a0a0) 3.06 to 2.98. Light pages stay solid
|
|
102
|
+
(3.10).
|
|
87
103
|
- **Back plays the app's page move the other way.** Browser Back (a Turbo restore that Turbo
|
|
88
104
|
marks `back`, or a full load that traverses to an earlier entry) and a click on a link with
|
|
89
105
|
`data-page-transition-back` (every ancestor crumb and the phone's parent link) play the
|
|
@@ -95,6 +111,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
95
111
|
|
|
96
112
|
### Fixed
|
|
97
113
|
|
|
114
|
+
- **A standard carousel's `caption:` is a styled line** (small body text under each slide),
|
|
115
|
+
not bare text; the partial's docs and the README say which style shows which captions.
|
|
98
116
|
- **Review follow-ups on the sidebar, the carousel and the page header** (/tasks/engine-ui-polish-follow-ups).
|
|
99
117
|
The link sidebar's focus ring (its rows, close button, Log out, trigger and geo rows) is
|
|
100
118
|
`ring-primary-ink`, a new ring colour from the theme's primary ink: measured 5.4 to 8.9:1
|
data/README.md
CHANGED
|
@@ -328,11 +328,20 @@ as a layout; the block draws each slide. `per_view:` is the wide row's count
|
|
|
328
328
|
<% end %>
|
|
329
329
|
```
|
|
330
330
|
|
|
331
|
+
`caption:` (a callable on the item) writes a small line under each slide.
|
|
332
|
+
|
|
331
333
|
`style: :spotlight` snaps each slide to the centre: the centred slide is full size and
|
|
332
|
-
colour, with its
|
|
333
|
-
are smaller, faded and grey. The arrows
|
|
334
|
-
|
|
335
|
-
centred slide (`data-current`) and CSS
|
|
334
|
+
colour, with its caption under it (the spotlight shows the centred slide's only), and its
|
|
335
|
+
neighbours are smaller, faded and grey. The arrows ("Previous slide", "Next slide") and
|
|
336
|
+
keys move one slide. Where the browser has scroll-driven animations the look follows the
|
|
337
|
+
scroll; elsewhere the controller marks the centred slide (`data-current`) and CSS
|
|
338
|
+
transitions it. Reduced motion only dims.
|
|
339
|
+
|
|
340
|
+
`start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight opens
|
|
341
|
+
on its middle slide by default, so neither half of the row is empty at load; a standard
|
|
342
|
+
row opens on its first. The browser sets it before the first paint where it has
|
|
343
|
+
`scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
|
|
344
|
+
glide and nothing announced. A `#fragment` link to an element inside the row wins.
|
|
336
345
|
|
|
337
346
|
```erb
|
|
338
347
|
<%= render layout: "studio/carousel",
|
|
@@ -685,6 +685,20 @@ body.modal-open .studio-app-banner {
|
|
|
685
685
|
flex: 0 0 calc((100% - var(--carousel-n) * var(--carousel-gap)) / (var(--carousel-n) + var(--carousel-peek)));
|
|
686
686
|
min-width: 0;
|
|
687
687
|
scroll-snap-align: start;
|
|
688
|
+
|
|
689
|
+
/* The partial's start: past the first slide. A browser with scroll-initial-target
|
|
690
|
+
opens the row on this slide before the first paint, so the start costs no jump;
|
|
691
|
+
elsewhere the controller sets it when it connects. */
|
|
692
|
+
&[data-carousel-start-slide] {
|
|
693
|
+
scroll-initial-target: nearest;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/* The caption's line under a slide (the partial's caption:). A standard row shows it
|
|
698
|
+
under every slide; the spotlight (below) shows it under the centred slide only. */
|
|
699
|
+
@utility carousel-caption {
|
|
700
|
+
margin-top: 0.5rem;
|
|
701
|
+
@apply text-sm text-body;
|
|
688
702
|
}
|
|
689
703
|
|
|
690
704
|
@utility carousel-controls {
|
|
@@ -2279,13 +2293,14 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2279
2293
|
stop. The violet, centered on the brand colour, is bright enough at its low stop. */
|
|
2280
2294
|
html.dark[data-surface-style="glass"] :is(.btn-secondary, .btn-danger, .btn-warning, .btn-success) { --lit-glow-stop: var(--lit-top-stop); }
|
|
2281
2295
|
/* A TOUCH OF TRANSPARENCY on a full-size filled button, dark theme only: a photo behind it
|
|
2282
|
-
shows
|
|
2296
|
+
shows through. At 0.65 (Alex McRitchie, 2026-11-05) white text on the primary measures 5.6:1
|
|
2297
|
+
on a dark page, 3.4:1 over a mid-grey photo and 3.0:1 over a light-mid one (#a0a0a0). An outer shadow is not painted
|
|
2283
2298
|
under its own box, so the glow never brightens the fill. On a light page the backdrop would
|
|
2284
2299
|
lift the fill and cost contrast, so it stays solid there. Without a blur, or without relative
|
|
2285
2300
|
oklch (the gradient would drop out and leave white text on nothing), it stays solid. */
|
|
2286
2301
|
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (color: oklch(from red l c h)) {
|
|
2287
2302
|
html.dark[data-surface-style="glass"] :is(.btn-primary, .btn-secondary, .btn-danger, .btn-warning, .btn-success):not(.btn-sm, :disabled, [aria-disabled="true"], [class*=" bg-"], [class^="bg-"]) {
|
|
2288
|
-
--lit-fill-alpha: 0.
|
|
2303
|
+
--lit-fill-alpha: 0.65;
|
|
2289
2304
|
background-color: transparent;
|
|
2290
2305
|
-webkit-backdrop-filter: blur(6px);
|
|
2291
2306
|
backdrop-filter: blur(6px);
|
|
@@ -97,3 +97,11 @@ export function centerStops(slides, clientWidth, max) {
|
|
|
97
97
|
if (slides.length === 0) return [0]
|
|
98
98
|
return slides.map(({ left, width }) => Math.min(max, Math.max(0, left + width / 2 - clientWidth / 2)))
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
// The stop a row opens on, from the partial's start index: a standard row with
|
|
102
|
+
// more slides than stops opens at its last stop.
|
|
103
|
+
export function startStop(start, count) {
|
|
104
|
+
if (!(count > 0)) return 0
|
|
105
|
+
const index = Math.floor(Number(start)) || 0
|
|
106
|
+
return Math.min(count - 1, Math.max(0, index))
|
|
107
|
+
}
|
|
@@ -19,9 +19,14 @@
|
|
|
19
19
|
// slide is a stop, an arrow moves one slide, and the slide nearest the centre
|
|
20
20
|
// carries data-current, which the CSS styles where scroll-driven animations are
|
|
21
21
|
// missing and under reduced motion.
|
|
22
|
-
|
|
22
|
+
//
|
|
23
|
+
// THE START (the partial's start:, data-carousel-start): a row that opens past its
|
|
24
|
+
// first slide. The browser lands there before the first paint where it has
|
|
25
|
+
// scroll-initial-target (engine.css); elsewhere connect() jumps there, with no glide
|
|
26
|
+
// and nothing in the status line. A row already moved (the browser's own start, a
|
|
27
|
+
// restored scroll, a #fragment inside it) is left where it is.
|
|
23
28
|
import { Controller } from "@hotwired/stimulus"
|
|
24
|
-
import { currentStop, hiddenEnds, keyAction, ownsKey, perPage, positionText, stops, targetStop } from "studio/carousel"
|
|
29
|
+
import { TOLERANCE, centerStops, currentStop, hiddenEnds, keyAction, ownsKey, perPage, positionText, startStop, stops, targetStop } from "studio/carousel"
|
|
25
30
|
|
|
26
31
|
export default class extends Controller {
|
|
27
32
|
static targets = ["track", "slide", "prev", "next", "dot", "status"]
|
|
@@ -34,9 +39,27 @@ export default class extends Controller {
|
|
|
34
39
|
this.observer.observe(this.trackTarget)
|
|
35
40
|
}
|
|
36
41
|
this.element.setAttribute("data-carousel-ready", "")
|
|
42
|
+
this.openAtStart()
|
|
37
43
|
this.sync()
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
openAtStart() {
|
|
47
|
+
const start = Number(this.element.dataset.carouselStart)
|
|
48
|
+
if (!(start > 0)) return
|
|
49
|
+
const track = this.trackTarget
|
|
50
|
+
if (Math.abs(track.scrollLeft) > TOLERANCE) return
|
|
51
|
+
const hash = window.location?.hash
|
|
52
|
+
if (hash && hash.length > 1) {
|
|
53
|
+
let target = null
|
|
54
|
+
try { target = document.getElementById(decodeURIComponent(hash.slice(1))) } catch { target = null }
|
|
55
|
+
if (target && track.contains(target)) return
|
|
56
|
+
}
|
|
57
|
+
const { positions } = this.measure()
|
|
58
|
+
const left = positions[startStop(start, positions.length)]
|
|
59
|
+
if (left === undefined) return
|
|
60
|
+
track.scrollTo({ left, behavior: "instant" })
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
disconnect() {
|
|
41
64
|
this.trackTarget.removeEventListener("scroll", this.schedule)
|
|
42
65
|
this.observer?.disconnect()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil) %>
|
|
1
|
+
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil) %>
|
|
2
2
|
<%# THE CAROUSEL: one sideways row of slides, the apps' standard for a set too
|
|
3
3
|
long to grid. Render it as a layout, so each slide is your own block:
|
|
4
4
|
|
|
@@ -21,8 +21,16 @@
|
|
|
21
21
|
style :standard (slides snap to the start) or :spotlight (the
|
|
22
22
|
centred slide is full size and colour, its neighbours smaller,
|
|
23
23
|
faded and grey, and the arrows and keys move one slide).
|
|
24
|
-
caption a callable that writes a line under a slide from its item.
|
|
25
|
-
|
|
24
|
+
caption a callable that writes a line under a slide from its item. A
|
|
25
|
+
standard row shows a small line under every slide; the
|
|
26
|
+
spotlight shows it under the centred slide only.
|
|
27
|
+
start the slide the row opens on: an index from 0, or :middle (the
|
|
28
|
+
slide at items.size / 2). Defaults to :middle for the spotlight,
|
|
29
|
+
so both halves of the row hold a slide at load, and to 0 for a
|
|
30
|
+
standard row. The browser sets it before the first paint where
|
|
31
|
+
it has scroll-initial-target; elsewhere the controller sets it
|
|
32
|
+
when it connects, with no glide and nothing announced. A link
|
|
33
|
+
to an id inside the row (#fragment) wins over the start.
|
|
26
34
|
|
|
27
35
|
THE SPOTLIGHT (engine.css, carousel-spotlight) snaps each slide to the centre.
|
|
28
36
|
Where the browser has scroll-driven animations, each slide's own view timeline
|
|
@@ -56,13 +64,20 @@
|
|
|
56
64
|
when "spotlight" then true
|
|
57
65
|
else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
|
|
58
66
|
end
|
|
67
|
+
start = spotlight ? :middle : 0 if start.nil?
|
|
68
|
+
start_index = case start
|
|
69
|
+
when :middle, "middle" then total / 2
|
|
70
|
+
when Integer then start.clamp(0, [total - 1, 0].max)
|
|
71
|
+
else raise ArgumentError, "studio/carousel start: #{start.inspect} is not an index or :middle"
|
|
72
|
+
end
|
|
73
|
+
arrow_noun = spotlight ? "slide" : "slides"
|
|
59
74
|
track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
|
|
60
75
|
%>
|
|
61
76
|
<%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), class_name].compact.join(" "),
|
|
62
77
|
role: "region",
|
|
63
78
|
aria: { roledescription: "carousel", label: label },
|
|
64
79
|
style: "--carousel-per-view: #{per_view}",
|
|
65
|
-
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key" }.merge(data)) do %>
|
|
80
|
+
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key", carousel_start: (start_index.to_s if start_index.positive?) }.compact.merge(data)) do %>
|
|
66
81
|
<%# The track takes focus for the keys, so it carries a name: a group, since a
|
|
67
82
|
bare div may not be named. %>
|
|
68
83
|
<div id="<%= track_id %>" class="carousel-track" tabindex="0" role="group"
|
|
@@ -70,25 +85,25 @@
|
|
|
70
85
|
<% items.each_with_index do |item, index| %>
|
|
71
86
|
<div class="carousel-slide" role="group" aria-roledescription="slide"
|
|
72
87
|
aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
|
|
73
|
-
data-carousel-target="slide"
|
|
88
|
+
data-carousel-target="slide"<%= ' data-carousel-start-slide'.html_safe if start_index.positive? && index == start_index %><%= ' data-current'.html_safe if spotlight && index == start_index %>><% if spotlight %><div class="carousel-spotlight-body"><%= yield(item) %></div><% else %><%= yield(item) %><% end %><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
|
|
74
89
|
<% end %>
|
|
75
90
|
</div>
|
|
76
91
|
<div class="carousel-controls">
|
|
77
92
|
<div class="carousel-dots" role="group" aria-label="Choose a slide">
|
|
78
93
|
<% total.times do |index| %>
|
|
79
94
|
<button type="button" class="carousel-dot" aria-label="Go to slide <%= index + 1 %>"
|
|
80
|
-
aria-controls="<%= track_id %>"<%= ' aria-current="true"'.html_safe if index
|
|
95
|
+
aria-controls="<%= track_id %>"<%= ' aria-current="true"'.html_safe if index == start_index %>
|
|
81
96
|
data-carousel-target="dot" data-carousel-index="<%= index %>"
|
|
82
97
|
data-studio-action="click->carousel#goTo"></button>
|
|
83
98
|
<% end %>
|
|
84
99
|
</div>
|
|
85
100
|
<div class="carousel-arrows">
|
|
86
|
-
<button type="button" class="carousel-button" aria-label="Previous
|
|
87
|
-
aria-disabled="
|
|
101
|
+
<button type="button" class="carousel-button" aria-label="Previous <%= arrow_noun %>" aria-controls="<%= track_id %>"
|
|
102
|
+
aria-disabled="<%= start_index.zero? %>" data-carousel-target="prev" data-studio-action="click->carousel#prev">
|
|
88
103
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12.5 15 7.5 10l5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
89
104
|
</button>
|
|
90
|
-
<button type="button" class="carousel-button" aria-label="Next
|
|
91
|
-
data-carousel-target="next" data-studio-action="click->carousel#next">
|
|
105
|
+
<button type="button" class="carousel-button" aria-label="Next <%= arrow_noun %>" aria-controls="<%= track_id %>"
|
|
106
|
+
<%= 'aria-disabled="true" '.html_safe if total.positive? && start_index == total - 1 %>data-carousel-target="next" data-studio-action="click->carousel#next">
|
|
92
107
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m7.5 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
93
108
|
</button>
|
|
94
109
|
</div>
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
|
|
265
265
|
<%= render layout: "style/specimen",
|
|
266
266
|
locals: { klass: "studio/_carousel (style: :spotlight)",
|
|
267
|
-
note: "Centres one slide at full size and colour, with its caption, and shrinks, fades and greys its neighbours as they scroll by.",
|
|
267
|
+
note: "Centres one slide at full size and colour, with its caption, and shrinks, fades and greys its neighbours as they scroll by. It opens on its middle slide; start: takes an index instead.",
|
|
268
268
|
usage: %(<%= render layout: "studio/carousel",\n locals: { label: "Team", items: @people, style: :spotlight,\n caption: ->(person) { person.title } } do |person| %\>\n <%= render "people/card", person: person %\>\n<% end %\>) } do %>
|
|
269
269
|
<div class="w-full min-w-0" data-carousel-spotlight-specimen>
|
|
270
270
|
<%= render layout: "studio/carousel",
|
data/lib/studio/version.rb
CHANGED