studio-engine 0.133.2 → 0.134.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: a0b1a25cbd9027f85de7e90d9e0b22f113d048a622979fe2a996ddac25526106
4
- data.tar.gz: 5501ce9e395be5812d0eb25d2cc53ab501f1f6f02a7afd0366d1429b506edda8
3
+ metadata.gz: 28427df3b5517371576bbd0cc5c159220393748f14c7a31ed39214a223fd0b86
4
+ data.tar.gz: 2268ea35dc8219f05a5dc2f1e28c639478538782ba664d69125f7e63556ddfac
5
5
  SHA512:
6
- metadata.gz: a0ad40b43a9674a2b7e67711bb85de70558331279efe955df165d8d96178a779e6ff12282c3d359224e1a78e605ae5f0cc874e7bc63e5d4a180f03161439202c
7
- data.tar.gz: 75d96b512a1c891a23a60c983e45a951fa1e909c9894579485074ec042420a1208895135078e29b8dad0564589d7818f9df4c27381f60eb2cd5ce26ecda0d938
6
+ metadata.gz: 7c48c6796c363df39d913088657f89ac9ee7ffec99e3b3f685095006890c405ec3de5f8aaee936a50d3aa1a4c4250a445de484c0debff38166917bd6a3f08f34
7
+ data.tar.gz: 3fe116b2ea6ebfd22e920cacb672ead1e4f999e7c0d5f15b31ad208653e104627d6305c65d2cc24b021041bff9f3e31d9d3569841d8fc51f88daae1b1d6e18a7
data/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.134.0 — 2026-11-07
8
+
9
+ ### Changed
10
+
11
+ - **A looping carousel now wraps visually.** `studio/_carousel` with `loop: true` and more than one slide copies its last slides before the first and its first slides after the last, so a spotlight opening on its first slide shows the last one beside it (the hub's `/websites` row). Next at the last slide, Previous at the first and autoplay glide across the seam and then jump silently onto the real slide; reduced motion goes straight there. The copies are `aria-hidden` and `inert` and drop ids, names, `data-test` hooks and Stimulus wiring, so a screen reader and Tab meet each slide once and the dots count real slides. Every slide of a looping standard row is now a stop (six slides three to a view show six dots, not four). Rows without `loop:` are unchanged. Nothing to do in a host; a host spec that counts `.carousel-slide` in a looping row should count `[data-carousel-target='slide']`.
12
+ - **A looping spotlight opens on its first slide.** With `style: :spotlight`, `loop: true`, more than one slide and no `start:`, the row now opens on slide 0, the host's first item, with the last slide showing on its left. It used to open on `:middle`. A spotlight that does not loop still opens on `:middle`, and an explicit `start:` still wins. A host that wants the old opening passes `start: :middle`.
13
+ - **Footer social icons are lit like the buttons.** A linked `.ftr-social` disc in the site footer now wears the glass lit button's family in every app, glass surface or not: a top highlight, a bottom edge, a contact shadow and a soft glow in its own fill, with the dark-mode values in dark mode. Hover still lifts and now deepens (never brightens, so the white glyph keeps its contrast). A pending (dashed) icon stays flat. No consumer change: it arrives with the engine bump.
14
+
7
15
  ## 0.133.2 — 2026-11-06
8
16
 
9
17
  ### Changed
data/README.md CHANGED
@@ -343,15 +343,23 @@ keys move one slide. Where the browser has scroll-driven animations the look fol
343
343
  scroll; elsewhere the controller marks the centred slide (`data-current`) and CSS
344
344
  transitions it. Reduced motion only dims.
345
345
 
346
- `start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight opens
347
- on its middle slide by default, so neither half of the row is empty at load; a standard
348
- row opens on its first. The browser sets it before the first paint where it has
346
+ `start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight that
347
+ does not loop opens on its middle slide by default, so neither half of the row is empty at
348
+ load; a standard row and a looping spotlight open on their first (a looping row's wrap
349
+ already shows the last slide left of the first). An explicit `start:` wins. The browser sets it before the first paint where it has
349
350
  `scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
350
351
  glide and nothing announced. A `#fragment` link to an element inside the row wins.
351
352
 
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.
353
+ `loop: true` wraps the row, and a row of more than one slide is visually continuous: the
354
+ controller copies the last slides before the first and the first slides after the last, so
355
+ with the first slide centred the last one shows beside it. Next at the last slide glides on
356
+ to the first and Previous at the first back to the last; once the row rests, it jumps with no
357
+ glide onto the real slide, which looks the same. Under reduced motion it goes straight there.
358
+ Neither arrow is ever disabled, and every slide of a looping row is a stop. A copy is
359
+ `aria-hidden` and `inert`, and drops its ids, names, test hooks (`data-test`, `data-testid`)
360
+ and Stimulus wiring, so a screen reader and Tab meet each slide once, a `#fragment` finds the
361
+ real slide, and the dots and the status line count the real slides. A slide that a controller
362
+ fills after load shows its server-rendered copy beside the seam.
355
363
 
356
364
  `autoplay:` is the seconds between moves (default `nil`, off). The row moves one slide each
357
365
  interval, in both styles. It holds still while the pointer is over the slides, while keyboard
@@ -1064,6 +1064,15 @@ body.modal-open .studio-app-banner {
1064
1064
  opacity: 0;
1065
1065
  }
1066
1066
 
1067
+ /* A looping row's jump across the seam (data-carousel-wrapping, one frame): the
1068
+ mark moves from a clone to the real slide it copies, in the same place, so
1069
+ nothing may transition. A clone is a carousel-slide, so every other rule here
1070
+ (the scroll-driven look, the mark) styles it as it styles a real slide. */
1071
+ &[data-carousel-wrapping] .carousel-slide .carousel-spotlight-body,
1072
+ &[data-carousel-wrapping] .carousel-slide .carousel-caption {
1073
+ transition: none;
1074
+ }
1075
+
1067
1076
  /* A neighbour holding focus drops the neighbour look on every path, so its focus
1068
1077
  ring shows at full strength (WCAG 2.4.7). It outranks the animation and the mark. */
1069
1078
  & .carousel-slide:focus-within .carousel-spotlight-body,
@@ -51,6 +51,8 @@ export function perPage(clientWidth, slideWidth, gap) {
51
51
  // A LOOPING row (loop: true) wraps instead: Next at the last stop goes to the
52
52
  // first, Previous at the first goes to the last. A page that would overshoot an
53
53
  // end still lands on that end first, so no slide is skipped on the way round.
54
+ // The controller uses this wrap only for a looping row it has not cloned; a cloned
55
+ // row (THE LOOP'S CLONES, below) moves by loopStop instead, round the seam.
54
56
  export function targetStop(action, current, page, count, loop = false) {
55
57
  const last = Math.max(0, count - 1)
56
58
  if (loop && last > 0) {
@@ -170,3 +172,77 @@ export function startStop(start, count) {
170
172
  const index = Math.floor(Number(start)) || 0
171
173
  return Math.min(count - 1, Math.max(0, index))
172
174
  }
175
+
176
+ // THE LOOP'S CLONES. A looping row (loop: true, more than one slide) is visually
177
+ // continuous: the controller copies the last slides before the first and the first
178
+ // slides after the last, so the space either side of an end shows the slides round
179
+ // the seam. A clone is aria-hidden and inert, with no id and no test hook, so a
180
+ // screen reader and Tab meet each slide once; the dots and the status line count the
181
+ // real slides only.
182
+ //
183
+ // Each place in that longer row has a VIRTUAL index: the real slides are 0 to
184
+ // total - 1, the clones before them -count to -1, the clones after total to
185
+ // total + count - 1. A move across the seam glides to a clone, and once the row
186
+ // rests there the controller jumps, with no glide, to the real slide it copies.
187
+
188
+ // The index of a real slide from a virtual one.
189
+ export function wrapIndex(index, total) {
190
+ if (!(total > 0)) return 0
191
+ return ((index % total) + total) % total
192
+ }
193
+
194
+ // How many clones each side: a wide row's slides plus one, enough to fill the view
195
+ // beside either end in both styles. None for a row of one slide.
196
+ export function cloneCount(perView, total) {
197
+ if (!(total > 1)) return 0
198
+ const view = Number(perView)
199
+ return Math.ceil(Number.isFinite(view) && view > 0 ? Math.min(view, 6) : 3) + 1
200
+ }
201
+
202
+ // The real slide each clone copies, in row order: `before` sits ahead of the first
203
+ // slide, `after` behind the last. A short row repeats round itself.
204
+ export function cloneLayout(total, count) {
205
+ if (!(total > 1) || !(count > 0)) return { before: [], after: [] }
206
+ const before = []
207
+ const after = []
208
+ for (let k = count; k >= 1; k -= 1) before.push(wrapIndex(-k, total))
209
+ for (let k = 0; k < count; k += 1) after.push(wrapIndex(k, total))
210
+ return { before, after }
211
+ }
212
+
213
+ // The virtual index an arrow, a key or autoplay takes a cloned row to: a page on
214
+ // either way, round the seam with no end. Home and End go to the first and last
215
+ // real slide. Held to the clones, so a press mid-glide never runs off the row.
216
+ export function loopStop(action, current, page, total, count) {
217
+ const to = { prev: current - page, next: current + page, first: 0, last: total - 1 }[action]
218
+ if (to === undefined) return current
219
+ return Math.min(total - 1 + count, Math.max(-count, to))
220
+ }
221
+
222
+ // The virtual index nearest `current` that shows real slide `index`: a dot takes
223
+ // the short way round the seam.
224
+ export function nearestVirtual(index, current, total, count) {
225
+ const options = [index - total, index, index + total].filter((v) => v >= -count && v <= total - 1 + count)
226
+ return options.reduce((best, v) => (Math.abs(v - current) < Math.abs(best - current) ? v : best), index)
227
+ }
228
+
229
+ // The real slide a row resting on a clone jumps to, or null when it rests on a real one.
230
+ export function seamJump(virtual, total) {
231
+ if (!(total > 0)) return null
232
+ return virtual < 0 || virtual >= total ? wrapIndex(virtual, total) : null
233
+ }
234
+
235
+ // The attributes a clone drops: ids and names (a clone must not answer a #fragment or
236
+ // join a form), test hooks, and Stimulus wiring (a clone runs no controller and is
237
+ // no one's target), and the carousel's own marks.
238
+ export const CLONE_STRIP = /^(id|name|data-(test|testid|test-id|qa|cy)|data-(studio-)?(controller|action)|data-[\w-]+-target|data-carousel-start-slide|data-current)$/
239
+
240
+ // The status line of a looping row, whose view may run round the seam:
241
+ // "Slides 5 to 6 and 1 of 6".
242
+ export function loopPositionText(first, shown, total) {
243
+ const count = Math.min(shown, total)
244
+ if (first + count <= total) return positionText(first, count, total)
245
+ const over = first + count - total
246
+ const head = first + 1 === total ? `Slides ${total}` : `Slides ${first + 1} to ${total}`
247
+ return `${head} and ${over === 1 ? "1" : `1 to ${over}`} of ${total}`
248
+ }
@@ -26,10 +26,21 @@
26
26
  // and nothing in the status line. A row already moved (the browser's own start, a
27
27
  // restored scroll, a #fragment inside it) is left where it is.
28
28
  //
29
- // A LOOPING row (loop:, data-carousel-loop) wraps: Next at the last stop goes back
30
- // to the first, Previous at the first to the last, and neither arrow is ever
31
- // aria-disabled. There are no cloned slides, so a screen reader reads each once:
32
- // the row glides back across itself.
29
+ // A LOOPING row (loop:, data-carousel-loop) of more than one slide is visually
30
+ // continuous. connect() copies the last slides before the first and the first slides
31
+ // after the last (cloneLayout), so the space beside either end shows the slides
32
+ // round the seam. Next at the last slide glides on to the clone of the first;
33
+ // once the row rests there (scrollend, or a short quiet spell where the browser has
34
+ // no scrollend) it jumps, with no glide and no transition, to the real first slide,
35
+ // whose neighbours look the same. Previous at the first does the same backwards, and
36
+ // a swipe that rests on a clone too. Under reduced motion the row goes straight to
37
+ // the real slide. Neither arrow is ever aria-disabled.
38
+ //
39
+ // A clone is aria-hidden and inert, keeps no id, name, test hook or Stimulus wiring
40
+ // (CLONE_STRIP), and is no slide target, so a screen reader and Tab meet each real
41
+ // slide once, a #fragment finds the real slide, and the dots and the status line
42
+ // count real slides. A click on a clone moves the row to it. disconnect() removes
43
+ // the clones, and connect() clears any a page snapshot kept.
33
44
  //
34
45
  // AUTOPLAY (autoplay: seconds, data-carousel-autoplay) moves the row one stop each
35
46
  // interval. It holds still while the pointer is over the slides, while keyboard focus
@@ -40,8 +51,9 @@
40
51
  // (APG: a rotating carousel does not announce). Without loop it stops at the end.
41
52
  import { Controller } from "@hotwired/stimulus"
42
53
  import {
43
- TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, currentStop, hiddenEnds, idleDelay,
44
- initialReasons, keyAction, ownsKey, perPage, positionText, startStop, stops, targetStop, toggleLabel, withReason
54
+ CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, cloneCount, cloneLayout,
55
+ currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
56
+ perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
45
57
  } from "studio/carousel"
46
58
 
47
59
  const REDUCE = "(prefers-reduced-motion: reduce)"
@@ -51,14 +63,22 @@ export default class extends Controller {
51
63
 
52
64
  connect() {
53
65
  this.schedule = this.schedule.bind(this)
66
+ this.settle = this.settle.bind(this)
67
+ this.clickClone = this.clickClone.bind(this)
54
68
  this.loop = this.element.hasAttribute("data-carousel-loop")
69
+ this.cloned = 0
70
+ this.removeClones()
71
+ this.scrollEnd = "onscrollend" in window
55
72
  this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
73
+ if (this.scrollEnd) this.trackTarget.addEventListener("scrollend", this.settle, { passive: true })
74
+ this.trackTarget.addEventListener("click", this.clickClone)
56
75
  if (typeof ResizeObserver === "function") {
57
76
  this.observer = new ResizeObserver(this.schedule)
58
77
  this.observer.observe(this.trackTarget)
59
78
  }
60
79
  this.element.setAttribute("data-carousel-ready", "")
61
80
  this.openAtStart()
81
+ this.addClones()
62
82
  this.sync()
63
83
  this.startAutoplay()
64
84
  }
@@ -82,10 +102,91 @@ export default class extends Controller {
82
102
 
83
103
  disconnect() {
84
104
  this.trackTarget.removeEventListener("scroll", this.schedule)
105
+ this.trackTarget.removeEventListener("scrollend", this.settle)
106
+ this.trackTarget.removeEventListener("click", this.clickClone)
85
107
  this.observer?.disconnect()
86
108
  if (this.frame) cancelAnimationFrame(this.frame)
87
109
  this.frame = null
110
+ clearTimeout(this.settleTimer)
111
+ this.settleTimer = null
88
112
  this.stopAutoplay()
113
+ this.removeClones()
114
+ }
115
+
116
+ // -- the loop's clones ------------------------------------------------------
117
+
118
+ // Copies the slides round the seam either side, and holds the row where it was:
119
+ // the scroll moves by the width the clones added before the first slide.
120
+ addClones() {
121
+ this.cloned = 0
122
+ if (!this.loop) return
123
+ const slides = this.slideTargets
124
+ const perView = parseFloat(getComputedStyle(this.element).getPropertyValue("--carousel-per-view"))
125
+ const { before, after } = cloneLayout(slides.length, cloneCount(perView, slides.length))
126
+ if (before.length === 0) return
127
+
128
+ const track = this.trackTarget
129
+ const first = slides[0]
130
+ const left = track.scrollLeft
131
+ const was = first.offsetLeft
132
+ first.before(...before.map((index) => this.cloneOf(slides[index])))
133
+ slides[slides.length - 1].after(...after.map((index) => this.cloneOf(slides[index])))
134
+ this.cloned = before.length
135
+ track.scrollTo({ left: left + first.offsetLeft - was, behavior: "instant" })
136
+ }
137
+
138
+ cloneOf(slide) {
139
+ const clone = slide.cloneNode(true)
140
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
141
+ for (const { name } of [...node.attributes]) if (CLONE_STRIP.test(name)) node.removeAttribute(name)
142
+ }
143
+ for (const name of ["role", "aria-roledescription", "aria-label"]) clone.removeAttribute(name)
144
+ clone.setAttribute("aria-hidden", "true")
145
+ clone.setAttribute("inert", "")
146
+ clone.setAttribute("data-carousel-clone", "")
147
+ return clone
148
+ }
149
+
150
+ removeClones() {
151
+ this.trackTarget.querySelectorAll(":scope > [data-carousel-clone]").forEach((clone) => clone.remove())
152
+ this.cloned = 0
153
+ }
154
+
155
+ // Every slide in row order, the clones included.
156
+ rowSlides() {
157
+ return this.cloned ? [...this.trackTarget.querySelectorAll(":scope > .carousel-slide")] : this.slideTargets
158
+ }
159
+
160
+ // The row came to rest. On a clone it jumps to the real slide the clone copies,
161
+ // at the same place in the row, so nothing on screen changes.
162
+ settle() {
163
+ this.settleTimer = null
164
+ if (!this.cloned) return
165
+ this.sync()
166
+ const real = seamJump(this.virtual, this.positions.length)
167
+ if (real === null) return
168
+ const at = this.full[this.virtual + this.cloned]
169
+ if (Math.abs(this.trackTarget.scrollLeft - at) > TOLERANCE) return
170
+ this.element.setAttribute("data-carousel-wrapping", "")
171
+ this.trackTarget.scrollTo({ left: this.positions[real], behavior: "instant" })
172
+ this.sync()
173
+ void this.trackTarget.offsetWidth
174
+ requestAnimationFrame(() => this.element.removeAttribute("data-carousel-wrapping"))
175
+ }
176
+
177
+ // An inert clone takes no click, so the click lands on the track: a click on a
178
+ // clone's box moves the row to it.
179
+ clickClone(event) {
180
+ if (!this.cloned || event.target !== this.trackTarget) return
181
+ const index = this.rowSlides().findIndex((slide) => {
182
+ if (!slide.hasAttribute("data-carousel-clone")) return false
183
+ const box = slide.getBoundingClientRect()
184
+ return event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom
185
+ })
186
+ if (index < 0) return
187
+ this.interacted()
188
+ this.sync()
189
+ this.scrollToStop(index - this.cloned)
89
190
  }
90
191
 
91
192
  // -- autoplay ---------------------------------------------------------------
@@ -183,7 +284,7 @@ export default class extends Controller {
183
284
  this.timer = null
184
285
  this.sync()
185
286
  const count = this.positions.length
186
- const to = autoplayStop(this.current, count, this.loop)
287
+ const to = this.cloned ? loopStop("next", this.virtual, 1, count, this.cloned) : autoplayStop(this.current, count, this.loop)
187
288
  if (to === null && count > 1) {
188
289
  // The end of a row that does not loop: autoplay is done until Play.
189
290
  this.pause("stopped", true)
@@ -194,6 +295,10 @@ export default class extends Controller {
194
295
  }
195
296
 
196
297
  schedule() {
298
+ if (this.cloned && !this.scrollEnd) {
299
+ clearTimeout(this.settleTimer)
300
+ this.settleTimer = setTimeout(this.settle, 150)
301
+ }
197
302
  if (this.frame) return
198
303
  this.frame = requestAnimationFrame(() => {
199
304
  this.frame = null
@@ -201,9 +306,11 @@ export default class extends Controller {
201
306
  })
202
307
  }
203
308
 
309
+ // The stops of every slide in the row. A cloned row measures its clones too, and
310
+ // every slide of it is a stop: the row never ends.
204
311
  measure() {
205
312
  const track = this.trackTarget
206
- const slides = this.slideTargets
313
+ const slides = this.rowSlides()
207
314
  if (this.element.classList.contains("carousel-spotlight")) {
208
315
  const max = Math.max(0, track.scrollWidth - track.clientWidth)
209
316
  const boxes = slides.map((slide) => ({ left: slide.offsetLeft, width: slide.offsetWidth }))
@@ -212,6 +319,12 @@ export default class extends Controller {
212
319
  const origin = slides[0]?.offsetLeft || 0
213
320
  const max = Math.max(0, track.scrollWidth - track.clientWidth)
214
321
  const gap = parseFloat(getComputedStyle(track).columnGap) || 0
322
+ if (this.cloned) {
323
+ return {
324
+ positions: slides.map((slide) => Math.min(max, Math.max(0, slide.offsetLeft - origin))),
325
+ page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
326
+ }
327
+ }
215
328
  return {
216
329
  positions: stops(slides.map((slide) => slide.offsetLeft - origin), max),
217
330
  page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
@@ -223,22 +336,35 @@ export default class extends Controller {
223
336
  const { positions, page } = this.measure()
224
337
  const ends = hiddenEnds(track)
225
338
 
226
- this.positions = positions
227
339
  this.page = page
228
- this.current = currentStop(track.scrollLeft, positions)
340
+ if (this.cloned) {
341
+ // positions holds every slide, clones included; this.positions the real ones.
342
+ const total = this.slideTargets.length
343
+ this.full = positions
344
+ this.virtual = currentStop(track.scrollLeft, positions) - this.cloned
345
+ this.current = wrapIndex(this.virtual, total)
346
+ this.positions = positions.slice(this.cloned, this.cloned + total)
347
+ } else {
348
+ this.full = null
349
+ this.positions = positions
350
+ this.current = currentStop(track.scrollLeft, positions)
351
+ this.virtual = this.current
352
+ }
229
353
 
230
- this.element.toggleAttribute("data-carousel-static", positions.length < 2)
354
+ this.element.toggleAttribute("data-carousel-static", this.positions.length < 2)
231
355
  track.toggleAttribute("data-fade-start", ends.start)
232
356
  track.toggleAttribute("data-fade-end", ends.end)
233
357
  // A looping row never disables an arrow: each end leads round to the other.
234
- const wraps = this.loop && positions.length > 1
358
+ const wraps = this.loop && this.positions.length > 1
235
359
  if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!wraps && !ends.start))
236
360
  if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && !ends.end))
237
361
  if (this.element.classList.contains("carousel-spotlight")) {
238
- this.slideTargets.forEach((slide, index) => slide.toggleAttribute("data-current", index === this.current))
362
+ // A clone is marked as a real slide is, so the fallback scales it the same.
363
+ const at = this.virtual + this.cloned
364
+ this.rowSlides().forEach((slide, index) => slide.toggleAttribute("data-current", index === at))
239
365
  }
240
366
  this.dotTargets.forEach((dot, index) => {
241
- dot.hidden = index >= positions.length
367
+ dot.hidden = index >= this.positions.length
242
368
  if (index === this.current) dot.setAttribute("aria-current", "true")
243
369
  else dot.removeAttribute("aria-current")
244
370
  })
@@ -251,7 +377,8 @@ export default class extends Controller {
251
377
  goTo(event) {
252
378
  this.interacted()
253
379
  this.sync()
254
- this.scrollToStop(Number(event.currentTarget.dataset.carouselIndex))
380
+ const index = Number(event.currentTarget.dataset.carouselIndex)
381
+ this.scrollToStop(this.cloned ? nearestVirtual(index, this.virtual, this.positions.length, this.cloned) : index)
255
382
  }
256
383
 
257
384
  key(event) {
@@ -270,17 +397,35 @@ export default class extends Controller {
270
397
  move(action) {
271
398
  this.interacted()
272
399
  this.sync()
400
+ if (this.cloned) {
401
+ const to = loopStop(action, this.virtual, this.page, this.positions.length, this.cloned)
402
+ if (to === this.virtual) return
403
+ this.scrollToStop(to)
404
+ return
405
+ }
273
406
  const to = targetStop(action, this.current, this.page, this.positions.length, this.loop)
274
407
  if (to === this.current) return
275
408
  this.scrollToStop(to)
276
409
  }
277
410
 
278
- // announce: false for autoplay, which moves the row without a word.
411
+ // announce: false for autoplay, which moves the row without a word. On a cloned
412
+ // row the index is virtual: a clone's place glides there and settle() jumps home,
413
+ // but under reduced motion the row goes straight to the real slide.
279
414
  scrollToStop(index, { announce = true } = {}) {
415
+ const reduce = window.matchMedia?.(REDUCE).matches
416
+ if (this.cloned) {
417
+ const total = this.positions.length
418
+ const real = wrapIndex(index, total)
419
+ const left = reduce ? this.positions[real] : this.full[index + this.cloned]
420
+ if (left === undefined) return
421
+ this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
422
+ if (announce && this.hasStatusTarget) this.statusTarget.textContent = loopPositionText(real, this.page, total)
423
+ return
424
+ }
425
+
280
426
  const left = this.positions[index]
281
427
  if (left === undefined) return
282
428
 
283
- const reduce = window.matchMedia?.(REDUCE).matches
284
429
  this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
285
430
 
286
431
  if (!announce || !this.hasStatusTarget) return
@@ -25,15 +25,25 @@
25
25
  standard row shows a small line under every slide; the
26
26
  spotlight shows it under the centred slide only.
27
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
28
+ slide at items.size / 2). Defaults to :middle for a spotlight
29
+ that does not loop, so both halves of the row hold a slide at
30
+ load, and to 0 for a standard row and for a looping spotlight
31
+ (its wrap already shows the last slide left of the first, so it
32
+ opens on the host's first item). An explicit start: wins. The browser sets it before the first paint where
31
33
  it has scroll-initial-target; elsewhere the controller sets it
32
34
  when it connects, with no glide and nothing announced. A link
33
35
  to an id inside the row (#fragment) wins over the start.
34
- loop true wraps the row: Next at the last slide goes to the first,
35
- Previous at the first to the last, and neither arrow is ever
36
- disabled. No slide is cloned, so a screen reader reads each once.
36
+ loop true wraps the row, and a row of more than one slide is
37
+ visually continuous: the controller copies the last slides
38
+ before the first and the first after the last, so the first
39
+ slide centred shows the last beside it. Next at the last slide
40
+ glides on to the first, Previous at the first back to the last,
41
+ then the row jumps silently onto the real slide (no glide under
42
+ reduced motion), and neither arrow is ever disabled. A copy is
43
+ aria-hidden and inert, with no id, name, test hook or controller,
44
+ so a screen reader and Tab meet each slide once, a #fragment
45
+ finds the real slide, and the dots and "slide N of M" count the
46
+ real slides. Every slide of a looping row is a stop.
37
47
  autoplay seconds between moves (a number above 0); nil, the default, is
38
48
  off. The row moves one slide each interval and holds still while
39
49
  the pointer is over the slides, while keyboard focus is in it,
@@ -75,7 +85,11 @@
75
85
  when "spotlight" then true
76
86
  else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
77
87
  end
78
- start = spotlight ? :middle : 0 if start.nil?
88
+ loop = loop ? true : false
89
+ wraps = loop && total > 1
90
+ # A looping spotlight has a left neighbour at its first slide (the clones), so it
91
+ # opens there; one that does not loop opens mid-row so neither half is empty.
92
+ start = spotlight && !wraps ? :middle : 0 if start.nil?
79
93
  start_index = case start
80
94
  when :middle, "middle" then total / 2
81
95
  when Integer then start.clamp(0, [total - 1, 0].max)
@@ -86,8 +100,6 @@
86
100
  raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
87
101
  end
88
102
  autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
89
- loop = loop ? true : false
90
- wraps = loop && total > 1
91
103
  arrow_noun = spotlight ? "slide" : "slides"
92
104
  track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
93
105
  %>
@@ -81,9 +81,32 @@
81
81
  .ftr-kit { margin: .75rem 0 0; }
82
82
  .ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
83
83
  .ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
84
- border-radius: 9999px; background: var(--ftr-primary); color: #fff; transition: transform .15s ease, filter .15s ease; }
85
- a.ftr-social:hover { transform: translateY(-2px); filter: brightness(1.12); }
86
- .ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; }
84
+ border-radius: 9999px; background: var(--ftr-primary); color: #fff;
85
+ transition: transform .15s ease, filter .15s ease, box-shadow .15s ease; }
86
+ /* LIT, LIKE THE BUTTONS, IN EVERY APP. A linked disc wears the glass lit button's
87
+ family (engine.css, html[data-surface-style="glass"] .btn-primary): a top
88
+ highlight, a bottom edge, a contact shadow and a soft glow in its own fill.
89
+ Each light reads the button's --lit-* token, which only a glass page defines,
90
+ and falls back to the button's own value, so a flat-surface app shades the
91
+ same way. The gradient runs from the fill DOWN only (never lighter than
92
+ --ftr-primary), so the white glyph never loses contrast; a browser without
93
+ relative oklch keeps the flat fill and the shadows. The glow is drawn from
94
+ --ftr-primary-rgb, which every browser reads. */
95
+ .ftr { --ftr-lit-top: var(--lit-top, inset 0 1px 0 rgb(255 255 255 / .35));
96
+ --ftr-lit-bottom: var(--lit-bottom, inset 0 -2px 0 rgb(0 0 0 / .2));
97
+ --ftr-lit-contact: var(--lit-contact, 0 1px 2px rgb(0 0 0 / .4)); --ftr-lit-glow-alpha: var(--lit-glow-alpha, .6); }
98
+ .dark .ftr { --ftr-lit-bottom: var(--lit-bottom, inset 0 -2px 0 rgb(0 0 0 / .3));
99
+ --ftr-lit-contact: var(--lit-contact, 0 1px 2px rgb(0 0 0 / .65)); --ftr-lit-glow-alpha: var(--lit-glow-alpha, .8); }
100
+ a.ftr-social { background-image: linear-gradient(var(--ftr-primary), oklch(from var(--ftr-primary) calc(l - .08) c h));
101
+ box-shadow: var(--ftr-lit-top), var(--ftr-lit-bottom), var(--ftr-lit-contact),
102
+ 0 4px 12px -4px rgb(var(--ftr-primary-rgb) / var(--ftr-lit-glow-alpha)); }
103
+ /* Hover lifts and deepens, as the lit button darkens (brightness .94): never
104
+ brighter, which would fade the white glyph. */
105
+ a.ftr-social:hover { transform: translateY(-2px); filter: brightness(.94);
106
+ box-shadow: var(--ftr-lit-top), var(--ftr-lit-bottom), var(--ftr-lit-contact),
107
+ 0 8px 16px -6px rgb(var(--ftr-primary-rgb) / var(--ftr-lit-glow-alpha)); }
108
+ /* A pending handle stays a flat dashed outline: no light, no shadow. */
109
+ .ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; box-shadow: none; }
87
110
  .ftr-social-letter { font-weight: 700; font-size: .95rem; line-height: 1; }
88
111
  .ftr-heading { margin: 0 0 1rem; font-size: 1rem; font-weight: 700; color: var(--ftr-ink); }
89
112
  .ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
data/docs/SITE_FOOTER.md CHANGED
@@ -242,8 +242,9 @@ Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links
242
242
  from 0.5 to 4; anything else is ignored. Without one, the first column is 1.5
243
243
  and the rest are 1 (the brand, from 1024px, is 1.7). Below 768px the two tracks
244
244
  are fixed at 1.2 and 1, and with more than four columns the hints are not used.
245
+ - **A linked social icon is lit like the buttons**, in every app, light and dark: a top highlight, an edge and a soft shadow in `--color-primary`.
245
246
  - **A social profile with a `nil` URL** renders its icon unlinked, with a dashed
246
- outline. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
247
+ outline and no shading. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
247
248
  `:youtube`; any other icon falls back to the label's first letter.
248
249
 
249
250
  ### The address and the map
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.133.2"
2
+ VERSION = "0.134.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.133.2
4
+ version: 0.134.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie