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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f090aa3b8a00d7e7a62e7395fdc02fff3c0fdf4063350a492e1e835932dc5b26
4
- data.tar.gz: 6d2ae853a4bb64281147c1a67721d93e1a8c38b5d3fb43e6897df85c7be964c7
3
+ metadata.gz: dd2c739840540a736a0ad347abe13b22047ff20070c8e336df86172c6518d848
4
+ data.tar.gz: fced70c6c90490c2d74732bdae8270b25f0e7717a87623daf734984c6e4fb101
5
5
  SHA512:
6
- metadata.gz: 7cc84fc8fad2d0b18b53e41b96e8d11bc878d8cdad732bc4a02b3581466fdafb927f3fbec54984e0ca04c839c85417008bef31f6efe21b347ebe4fce7ad7672b
7
- data.tar.gz: 2b005e75062ef195f1178074f97efc738d798e69ee9dccbfb58cd88f1155760f4cbc1712da6bbd8de624673fce6d76a2f0982a0b8b3a58d615add5fc2f626c87
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 slide gets a dot. Where the browser
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 `caption:` line (a callable on the item) under it, and its neighbours
333
- are smaller, faded and grey. The arrows and keys move one slide. Where the browser has
334
- scroll-driven animations the look follows the scroll; elsewhere the controller marks the
335
- centred slide (`data-current`) and CSS transitions it. Reduced motion only dims.
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 subtly, and white text holds its contrast at the midpoint. An outer shadow is not painted
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.85;
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
- import { centerStops } from "studio/carousel"
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
- The spotlight shows it under the centred slide only.
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"><% 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>
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.zero? %>
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 slides" aria-controls="<%= track_id %>"
87
- aria-disabled="true" data-carousel-target="prev" data-studio-action="click->carousel#prev">
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 slides" aria-controls="<%= track_id %>"
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",
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.131.0"
2
+ VERSION = "0.132.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.131.0
4
+ version: 0.132.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie