@mnstry/atelier 0.2.0-alpha.17 → 0.2.0-alpha.18

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.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0-alpha.18
4
+
5
+ - Carousel autoplay keeps running under the pointer. It now holds for a press (any click, tap or drag), keyboard focus (not focus from a click), a wheel, an expanded slide, a hidden tab or the carousel being off screen, and resumes `data-resume` ms (default 2000) after the reader lets go; a smooth drift eases back to speed instead of jumping.
6
+ - A thrown drifting strip coasts longer and stops where it stops: nothing pulls it back to a card or to its earlier resting place, and the loop renumbers invisibly over its clones while coasting. A scrub, or a slide brought to the centre, also stays put until an arrow or a drag moves it.
7
+ - Opening a `<details>` inside a slide (a quick view, for example) brings that slide to the centre and holds autoplay until every expanded panel in the strip is closed.
8
+ - In a loop, the scroll bar's end now shows the last slide at the viewport's inner end edge (measured, so side insets are respected), and the thumb wraps: as the strip drifts it leaves the right end and re-enters on the left. `renderCarouselControls` emits a second thumb segment for this; an enhancer adds it to older markup.
9
+ - New pure helpers: `loopThumbGeometry`, `rampFactor`, `resumeDelay`.
10
+
3
11
  ## 0.2.0-alpha.17
4
12
 
5
- - Add UI primitives (`@mnstry/atelier/ui/primitives` and `/ui/primitives/browser`), the shared behaviour layer beside the control system. The carousel combines the Urana Heart site's carousel (seamless loops over inert clones, horizontal drag intent, flick steps, focus kept off inert slides, page dots and thumbnails, a polite live status, height that follows the slide, receding neighbours and a still row when every card fits, and a crossfade mode) with Botanika's additions (a draggable scroll bar, capped momentum for multi-card strips, page stepping that never reads as backwards in a short loop, and discrete or smooth autoplay with a pause control that holds on hover, focus, touch, wheel, drag, a hidden tab or off screen and never runs under reduced motion). A `visible` selection mode keeps every on-screen card interactive for product strips. Also: interruptible `<details>` disclosures, the counted layout-stable scroll lock from the portable presentation contract (measured from the body's laid-out width, so it holds with `scrollbar-gutter: stable`), and one motion owner. Pure state is exported for Node; tenants theme through `--system-carousel-*`. See the [UI primitives reference](docs/reference/ui-primitives.md).
13
+ - Add UI primitives (`@mnstry/atelier/ui/primitives` and `/ui/primitives/browser`), the shared behaviour layer beside the control system. The carousel provides seamless loops over inert clones, horizontal drag intent, flick steps, focus kept off inert slides, page dots and thumbnails, a polite live status, height that follows the slide, receding neighbours and a still row when every card fits, and a crossfade mode, plus a draggable scroll bar, capped momentum for multi-card strips, page stepping that never reads as backwards in a short loop, and discrete or smooth autoplay with a pause control that holds on hover, focus, touch, wheel, drag, a hidden tab or off screen and never runs under reduced motion. A `visible` selection mode keeps every on-screen card interactive for product strips. Also: interruptible `<details>` disclosures, the counted layout-stable scroll lock from the portable presentation contract (measured from the body's laid-out width, so it holds with `scrollbar-gutter: stable`), and one motion owner. Pure state is exported for Node; tenants theme through `--system-carousel-*`. See the [UI primitives reference](docs/reference/ui-primitives.md).
6
14
 
7
15
  ## 0.2.0-alpha.16
8
16
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MNSTRY Atelier
2
2
 
3
- `0.2.0-alpha.17` adds shared [UI primitives](docs/reference/ui-primitives.md): one carousel, disclosure, scroll lock and motion layer for Atelier-built sites. `0.2.0-alpha.16` renames the [graph workspace](docs/guides/graph-workspace.md)'s room to the map and its middle layer to distribution, keeping the alpha.15 names for one release. `0.2.0-alpha.15` added the workspace (a map of every readable record, a focused authoring workbench and a review room, built from primitives distributions and owners can compose), renderer-neutral graph views and an [extension SDK and Postgres project workflow](docs/guides/extensions.md). Native host qualification remains a separate gate.
3
+ `0.2.0-alpha.18` refines carousel motion (gentle resume, coasting throws, centred expanded slides, a looping scroll bar). `0.2.0-alpha.17` adds shared [UI primitives](docs/reference/ui-primitives.md): one carousel, disclosure, scroll lock and motion layer for Atelier-built sites. `0.2.0-alpha.16` renames the [graph workspace](docs/guides/graph-workspace.md)'s room to the map and its middle layer to distribution, keeping the alpha.15 names for one release. `0.2.0-alpha.15` added the workspace (a map of every readable record, a focused authoring workbench and a review room, built from primitives distributions and owners can compose), renderer-neutral graph views and an [extension SDK and Postgres project workflow](docs/guides/extensions.md). Native host qualification remains a separate gate.
4
4
 
5
5
  **Turn a repository into a living, governed system.**
6
6
 
@@ -9,6 +9,13 @@ workspace between Atelier package releases. The flow is local-only: it does not
9
9
  provision repositories, contact a Git host, mutate the MNSTRY runtime, or write
10
10
  through a browser view.
11
11
 
12
+ ## Upgrading to 0.2.0-alpha.18
13
+
14
+ Carousel autoplay no longer holds on hover; it holds for a press, keyboard
15
+ focus, a wheel or an expanded slide and resumes after `data-resume` ms. A site
16
+ that relied on hover to pause autoplay should rely on the pause control. No
17
+ stored record changes format.
18
+
12
19
  ## Upgrading to 0.2.0-alpha.17
13
20
 
14
21
  This release only adds `@mnstry/atelier/ui/primitives` and
@@ -10,10 +10,10 @@ without script, and theming only through `--system-*` custom properties.
10
10
  | `@mnstry/atelier/ui/primitives` | Node and browsers | Pure state functions, `carouselStyles`, `renderCarouselControls` |
11
11
  | `@mnstry/atelier/ui/primitives/browser` | Browsers | `enhanceCarousels`, `enhanceDisclosures`, `lockScroll` and `unlockScroll`, the motion owner |
12
12
 
13
- The carousel brings together the Urana Heart site's carousel (seamless loops,
14
- drag intent, focus safety, page dots and thumbnails, live status, height that
15
- follows the slide, receding neighbours, still rows) and Botanika's additions (a
16
- draggable scroll bar, momentum for multi-card strips, autoplay, a pause control).
13
+ The carousel covers slideshows and multi-card strips: seamless loops, drag
14
+ intent, focus safety, page dots and thumbnails, live status, height that follows
15
+ the slide, receding neighbours, still rows, a draggable scroll bar, momentum,
16
+ autoplay and a pause control.
17
17
 
18
18
  ## Carousel
19
19
 
@@ -52,6 +52,7 @@ track's children; if any child carries `data-slide`, only those count.
52
52
  | `data-peek` | present | Centre-aligned slides; neighbours recede (`--d` from 0 to 1) and clicking one centres it; a still row when every card fits at `data-row-min` (default 400px) |
53
53
  | `data-fit` | `current` | The viewport height follows the slide in view, blending as slides pass |
54
54
  | `data-autoplay` | `off` (default), `discrete`, `smooth` | Advance one page every `data-interval` ms (at least 2500), or drift continuously at `data-speed` px/s |
55
+ | `data-resume` | ms, default 2000 | How long autoplay waits after the reader lets go before easing back in |
55
56
 
56
57
  ### Behaviour
57
58
 
@@ -65,11 +66,22 @@ track's children; if any child carries `data-slide`, only those count.
65
66
  - **Arrows, pages and keys:** Previous, Next, page buttons, and Left, Right,
66
67
  Home and End on the carousel. Reader-initiated moves are announced politely in
67
68
  `[data-status]`; autoplay moves are not.
68
- - **Autoplay** never runs under reduced motion. It holds while the pointer is
69
- over the carousel, while focus is inside it, during a touch, wheel, drag or
70
- scrub, while the tab is hidden and while the carousel is off screen. The
71
- rendered toggle pauses and resumes it. `data-autoplay-state` reads `playing`,
72
- `held`, `paused` or `off`.
69
+ - **Autoplay** never runs under reduced motion. It keeps running under the
70
+ pointer, and holds for a press (any click, tap or drag), keyboard focus inside
71
+ the carousel, a wheel, an expanded slide, a hidden tab or the carousel being
72
+ off screen. It resumes `data-resume` ms after the reader lets go, and a smooth
73
+ drift eases back to speed over about a second. The rendered toggle pauses and
74
+ resumes it. `data-autoplay-state` reads `playing`, `held`, `paused` or `off`.
75
+ - **Throws and resting places:** in a drifting strip, a thrown strip coasts to a
76
+ stop where it stops; nothing pulls it back to a card or to where it was. A
77
+ scrub, or a slide brought to the centre, also stays where the reader left it
78
+ until an arrow or a drag moves it again.
79
+ - **Expanded slides:** opening a `<details>` inside a slide (a quick view, for
80
+ example) brings that slide to the centre and holds autoplay until every
81
+ expanded panel in the strip is closed again.
82
+ - **Looping scroll bar:** in a loop the thumb is a window over one period. As the
83
+ strip drifts, the thumb leaves the right end and re-enters on the left.
84
+ Dragging the thumb to the right end shows the last slide at the right edge.
73
85
  - **Focus:** before a slide becomes inert, focus moves to its page button or
74
86
  another control, so it is never lost.
75
87
 
@@ -71,7 +71,8 @@
71
71
  "0.2.0-alpha.14",
72
72
  "0.2.0-alpha.15",
73
73
  "0.2.0-alpha.16",
74
- "0.2.0-alpha.17"
74
+ "0.2.0-alpha.17",
75
+ "0.2.0-alpha.18"
75
76
  ],
76
77
  "packRefs": []
77
78
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnstry/atelier",
3
- "version": "0.2.0-alpha.17",
3
+ "version": "0.2.0-alpha.18",
4
4
  "private": false,
5
5
  "description": "Local toolkit that turns a Git repository into an ontology-governed knowledge graph, enforcement layer, and runtime for people, agents, and tools. No telemetry or required hosted account; optional host-bound vault adapters.",
6
6
  "keywords": [
@@ -11,14 +11,13 @@
11
11
  * and discrete or smooth autoplay with a pause control.
12
12
  */
13
13
  import {
14
- arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, nearestCopy, releaseVelocity,
14
+ arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, loopThumbGeometry, nearestCopy, rampFactor, releaseVelocity, resumeDelay,
15
15
  pageSize, recede, rowFit, scrubPosition, slideWindow, swipeStep, thumbGeometry, wrap,
16
16
  } from './state.mjs'
17
17
  import { animationAllowed, onMotionChange } from './motion.mjs'
18
18
 
19
19
  const q = (root, selectors) => root.querySelector(selectors)
20
20
  const qa = (root, selectors) => [...root.querySelectorAll(selectors)]
21
- const finePointer = () => matchMedia('(hover: hover) and (pointer: fine)').matches
22
21
 
23
22
  // Move keyboard focus before its slide becomes inert or hidden.
24
23
  function focusAvailable(candidates, reveal = false) {
@@ -83,6 +82,8 @@ export function enhanceCarousel(root) {
83
82
 
84
83
  function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopDrift, canRun }) {
85
84
  const reasons = new Set()
85
+ const pause = resumeDelay(root.dataset.resume)
86
+ const timers = new Map()
86
87
  let userPaused = root.dataset.autoplayPaused !== undefined
87
88
  let onScreen = true
88
89
  let timer = 0
@@ -102,45 +103,54 @@ function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopD
102
103
  toggle.setAttribute('aria-label', userPaused ? 'Play automatic scrolling' : 'Pause automatic scrolling')
103
104
  toggle.dataset.state = userPaused ? 'paused' : 'playing'
104
105
  }
105
- const hold = reason => { reasons.add(reason); stop(); sync() }
106
+ // A later hold for the same reason cancels a pending release.
107
+ const hold = reason => { clearTimeout(timers.get(reason)); timers.delete(reason); reasons.add(reason); stop(); sync() }
106
108
  const release = (reason, delay = 0) => {
107
- const go = () => { reasons.delete(reason); start(); sync() }
108
- delay ? setTimeout(go, delay) : go()
109
+ clearTimeout(timers.get(reason))
110
+ const go = () => { timers.delete(reason); reasons.delete(reason); start(); sync() }
111
+ if (delay) timers.set(reason, setTimeout(go, delay)); else go()
109
112
  }
110
113
  if (autoplay.mode !== 'off') {
114
+ // Autoplay keeps running under the pointer. It holds for a press (any click,
115
+ // tap or drag), keyboard focus, a wheel, an expanded slide, a hidden tab or
116
+ // off screen, and resumes gently `data-resume` ms (default 2000) after the
117
+ // reader lets go.
111
118
  toggle?.addEventListener('click', () => { userPaused = !userPaused; start(); sync() })
112
- root.addEventListener('mouseenter', () => { if (finePointer()) hold('hover') })
113
- root.addEventListener('mouseleave', () => release('hover'))
114
- root.addEventListener('focusin', event => { if (!toggle?.contains(event.target)) hold('focus') })
115
- root.addEventListener('focusout', event => { if (!root.contains(event.relatedTarget)) release('focus') })
116
- root.addEventListener('pointerdown', event => { if (event.pointerType !== 'mouse') hold('touch') })
117
- const lift = event => { if (event.pointerType !== 'mouse') release('touch', 2500) }
119
+ root.addEventListener('pointerdown', event => { if (!toggle?.contains(event.target)) hold('press') })
120
+ const lift = () => release('press', pause)
118
121
  root.addEventListener('pointerup', lift)
119
122
  root.addEventListener('pointercancel', lift)
123
+ root.addEventListener('focusin', event => { if (!toggle?.contains(event.target) && event.target.matches?.(':focus-visible')) hold('focus') })
124
+ root.addEventListener('focusout', event => { if (!root.contains(event.relatedTarget)) release('focus', pause) })
120
125
  let wheelTimer = 0
121
- root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'), 2500) }, { passive: true })
126
+ root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'), pause) }, { passive: true })
122
127
  onMotionChange(() => { start(); sync() })
123
128
  if ('IntersectionObserver' in window) new IntersectionObserver(entries => { onScreen = entries.some(entry => entry.isIntersecting); start(); sync() }, { threshold: 0.25 }).observe(root)
124
129
  }
125
- return { start, stop, hold, release, sync, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
130
+ return { start, stop, hold, release, sync, pause, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
126
131
  }
127
132
 
128
133
  /* ------------------------------------------------------------- scroll bar */
129
134
 
130
135
  function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
131
- const thumb = scrollbar?.querySelector('span, [data-scrollbar-thumb]')
132
- if (!scrollbar || !thumb) return () => {}
136
+ if (!scrollbar) return () => {}
137
+ // Two thumb segments: in a loop, the part of the window past the end is drawn
138
+ // again from the start, so the thumb leaves on the right and re-enters on the left.
139
+ const thumbs = [...scrollbar.querySelectorAll(':scope > span, [data-scrollbar-thumb]')]
140
+ if (!thumbs.length) return () => {}
141
+ while (thumbs.length < 2) { const extra = document.createElement('span'); scrollbar.append(extra); thumbs.push(extra) }
133
142
  scrollbar.addEventListener('pointerdown', event => {
134
143
  if (event.button > 0) return
135
144
  event.preventDefault()
136
145
  const rail = scrollbar.getBoundingClientRect()
137
- const box = thumb.getBoundingClientRect()
138
- const onThumb = event.clientX >= box.left && event.clientX <= box.right
139
- const grab = onThumb ? event.clientX - box.left : box.width / 2
146
+ const box = thumbs[0].getBoundingClientRect()
147
+ const width = Math.min(box.width, rail.width)
148
+ const onThumb = event.clientX >= box.left && event.clientX <= Math.min(box.right, rail.right)
149
+ const grab = onThumb ? event.clientX - box.left : width / 2
140
150
  scrollbar.setPointerCapture(event.pointerId)
141
151
  root.dataset.scrubbing = ''
142
152
  begin()
143
- const move = x => scrubTo(scrubPosition(x, rail.left, rail.width, box.width, grab, geometry().range))
153
+ const move = x => scrubTo(scrubPosition(x, rail.left, rail.width, width, grab, geometry().range))
144
154
  move(event.clientX)
145
155
  const onMove = moveEvent => move(moveEvent.clientX)
146
156
  const onUp = () => { scrollbar.removeEventListener('pointermove', onMove); delete root.dataset.scrubbing; end() }
@@ -149,10 +159,14 @@ function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
149
159
  scrollbar.addEventListener('pointercancel', onUp, { once: true })
150
160
  })
151
161
  return () => {
152
- const { position, range, viewport } = geometry()
153
- const { width, left } = thumbGeometry(position, range, viewport)
154
- thumb.style.width = `${width * 100}%`
155
- thumb.style.left = `${left * 100}%`
162
+ const { position, range, viewport, period } = geometry()
163
+ // In a loop the thumb spans the part of the period not covered by the scroll range, so it
164
+ // reaches the right end exactly when the last slide does, and wraps only beyond that.
165
+ const { width, left } = period ? loopThumbGeometry(position, period, period - range) : thumbGeometry(position, range, viewport)
166
+ thumbs[0].style.width = thumbs[1].style.width = `${width * 100}%`
167
+ thumbs[0].style.left = `${left * 100}%`
168
+ thumbs[1].style.left = `${(left - 1) * 100}%`
169
+ thumbs[1].hidden = !period
156
170
  scrollbar.hidden = range <= 1
157
171
  }
158
172
  }
@@ -174,6 +188,8 @@ function initTrack(parts) {
174
188
  let items = slides
175
189
  let step = 0, current = -1, target = null, announce = false, row = false, timer = 0, realigned = false
176
190
  let home = 0, intent = false, drifting = false, gliding = 0, scrubbing = false
191
+ // free: the reader left the strip between cards (a scrub, a throw, a centred slide); nothing pulls it back.
192
+ let free = false
177
193
  let heights = [], padY = 0, padTop = 0
178
194
  let drag = null, suppressClick = false
179
195
 
@@ -214,10 +230,18 @@ function initTrack(parts) {
214
230
  heights = slides.map(slide => slide.offsetHeight)
215
231
  }
216
232
 
233
+ // The scroll position at which the last real slide sits at the viewport's inner end edge
234
+ // (measured, so side insets and gaps are respected), relative to the start of the loop.
235
+ const loopEnd = () => {
236
+ const last = slides.at(-1)
237
+ const box = viewport.getBoundingClientRect(), edge = parseFloat(getComputedStyle(viewport).scrollPaddingRight) || 0
238
+ const end = viewport.scrollLeft + last.getBoundingClientRect().right - (box.right - edge)
239
+ return Math.max(0, wrap(end - first() * step, n * step) || 0)
240
+ }
217
241
  const geometry = () => {
218
242
  if (sets) {
219
243
  const period = n * step
220
- return { position: wrap(viewport.scrollLeft - first() * step, period), range: Math.max(0, period - viewport.clientWidth), viewport: viewport.clientWidth }
244
+ return { position: wrap(viewport.scrollLeft - first() * step, period), range: loopEnd(), viewport: viewport.clientWidth, period }
221
245
  }
222
246
  return { position: viewport.scrollLeft, range: Math.max(0, viewport.scrollWidth - viewport.clientWidth), viewport: viewport.clientWidth }
223
247
  }
@@ -225,7 +249,7 @@ function initTrack(parts) {
225
249
  geometry,
226
250
  scrubTo: offset => { viewport.scrollLeft = (sets ? first() * step : 0) + offset },
227
251
  begin: () => { stopGlide(); scrubbing = true; intent = true; viewport.style.scrollSnapType = 'none'; player.hold('scrub') },
228
- end: () => { scrubbing = false; intent = true; settleSoon(); player.release('scrub') },
252
+ end: () => { scrubbing = false; free = true; settleSoon(); player.release('scrub', player.pause) },
229
253
  })
230
254
 
231
255
  const paint = () => {
@@ -247,11 +271,26 @@ function initTrack(parts) {
247
271
  if (count) count.textContent = `${(c % n) + 1} / ${n}`
248
272
  }
249
273
 
274
+ // Keep a looping strip inside the real slides by moving whole periods over identical clones (invisible).
275
+ const loopBase = () => first() * step
276
+ const normalizeTo = position => (sets && step ? loopBase() + wrap(position - loopBase(), n * step) : position)
277
+ const normalize = () => {
278
+ const position = viewport.scrollLeft, wrapped = normalizeTo(position)
279
+ if (Math.abs(wrapped - position) > 0.5) viewport.scrollTo({ left: wrapped, behavior: 'instant' })
280
+ }
281
+
250
282
  // Once still: step back onto the real slide showing the same content (identical, so invisible).
251
283
  const settle = () => {
252
284
  if (row || !step || drag || gliding || scrubbing) return
253
285
  if (drifting) return
254
- if (player.smooth && player.active()) return
286
+ // Drifting strips and free positions stay exactly where the reader left them.
287
+ if (player.smooth || free) {
288
+ normalize()
289
+ home = sets ? wrap(Math.round(position()) - first(), n) : Math.round(position())
290
+ intent = false; target = null; realigned = false
291
+ paint()
292
+ return
293
+ }
255
294
  if (!intent) {
256
295
  if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
257
296
  const goal = (first() + home) * step
@@ -279,6 +318,7 @@ function initTrack(parts) {
279
318
  if (row || !step) return
280
319
  stopGlide()
281
320
  intent = true
321
+ free = false
282
322
  let base = target ?? Math.round(position())
283
323
  if (sets) {
284
324
  const equivalent = homeIndex(base, n, sets)
@@ -339,17 +379,24 @@ function initTrack(parts) {
339
379
  }
340
380
 
341
381
  // Momentum for multi-card strips; slideshows step one slide per swipe instead.
342
- function stopGlide() { cancelAnimationFrame(gliding); gliding = 0 }
382
+ // A drifting strip coasts longer and stops where it stops; others settle on a card.
383
+ function stopGlide() { if (gliding) { cancelAnimationFrame(gliding); gliding = 0; player.release('glide', player.pause) } }
343
384
  const glide = velocity => {
344
- if (!animationAllowed() || Math.abs(velocity) < 0.05) { settle(); return }
385
+ const coast = player.smooth
386
+ if (!animationAllowed() || Math.abs(velocity) < 0.05) { if (coast) free = true; settle(); return }
387
+ player.hold('glide')
345
388
  let last = performance.now(), pos = viewport.scrollLeft
346
389
  const frame = now => {
347
- const next = glideStep(pos, velocity, now - last)
348
- last = now; pos = next.position; velocity = next.velocity
390
+ const next = glideStep(pos, velocity, now - last, coast ? 0.975 : 0.94)
391
+ last = now; velocity = next.velocity
392
+ pos = normalizeTo(next.position)
349
393
  viewport.scrollLeft = pos
350
394
  const atEdge = !sets && (pos <= 0 || pos >= viewport.scrollWidth - viewport.clientWidth)
351
- if (Math.abs(velocity) > 0.03 && !atEdge) gliding = requestAnimationFrame(frame)
352
- else { gliding = 0; settle() }
395
+ if (Math.abs(velocity) > (coast ? 0.01 : 0.03) && !atEdge) { gliding = requestAnimationFrame(frame); return }
396
+ gliding = 0
397
+ if (coast) free = true
398
+ settle()
399
+ player.release('glide', player.pause)
353
400
  }
354
401
  gliding = requestAnimationFrame(frame)
355
402
  }
@@ -362,11 +409,14 @@ function initTrack(parts) {
362
409
  startDrift: () => {
363
410
  drifting = true
364
411
  viewport.style.scrollSnapType = 'none'
365
- let pos = viewport.scrollLeft, last = 0
412
+ let pos = viewport.scrollLeft, last = 0, began = 0
366
413
  const frame = now => {
414
+ if (!began) began = now
367
415
  if (last) {
368
- if (sets) pos = first() * step + driftStep(pos - first() * step, parts.autoplay.speed, now - last, n * step)
369
- else pos = driftStep(pos, parts.autoplay.speed, now - last, 0, viewport.scrollWidth - viewport.clientWidth)
416
+ // Ease back in after a pause instead of jumping to full speed.
417
+ const speed = parts.autoplay.speed * rampFactor(now - began, 1200)
418
+ if (sets) pos = first() * step + driftStep(pos - first() * step, speed, now - last, n * step)
419
+ else pos = driftStep(pos, speed, now - last, 0, viewport.scrollWidth - viewport.clientWidth)
370
420
  viewport.scrollLeft = pos
371
421
  }
372
422
  last = now
@@ -377,11 +427,32 @@ function initTrack(parts) {
377
427
  stopDrift: () => {
378
428
  if (!driftFrame) return
379
429
  cancelAnimationFrame(driftFrame); driftFrame = 0; drifting = false
430
+ free = true
380
431
  home = ((Math.round(position()) - first()) % n + n) % n
381
432
  },
382
433
  })
383
434
  if (player.smooth) root.dataset.drift = ''
384
435
 
436
+ // Something expanded inside a slide (a quick view, a details panel): centre that slide and hold
437
+ // autoplay until everything inside the strip is closed again.
438
+ const expanded = () => slides.some(slide => slide.querySelector('details[open]'))
439
+ root.addEventListener('toggle', event => {
440
+ const details = event.target
441
+ if (!(details instanceof HTMLDetailsElement) || !track.contains(details) || details.closest('[data-clone]')) return
442
+ if (details.open) {
443
+ player.hold('expanded')
444
+ stopGlide()
445
+ const slide = slides.find(item => item.contains(details))
446
+ if (!slide || row || !step) return
447
+ free = true
448
+ viewport.style.scrollSnapType = 'none'
449
+ const box = viewport.getBoundingClientRect(), card = slide.getBoundingClientRect()
450
+ viewport.scrollBy({ left: card.left + card.width / 2 - (box.left + box.width / 2), behavior: animationAllowed() ? 'smooth' : 'instant' })
451
+ } else if (!expanded()) {
452
+ player.release('expanded', player.pause)
453
+ }
454
+ }, true)
455
+
385
456
  viewport.addEventListener('scroll', () => {
386
457
  requestAnimationFrame(paint)
387
458
  if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) }
@@ -421,7 +492,10 @@ function initTrack(parts) {
421
492
  }
422
493
  }
423
494
  if (drag.state === 'horizontal') {
424
- viewport.scrollLeft = drag.left - dx
495
+ let to = drag.left - dx
496
+ const wrapped = normalizeTo(to)
497
+ drag.left += wrapped - to; to = wrapped
498
+ viewport.scrollLeft = to
425
499
  drag.samples.push({ x: event.clientX, t: event.timeStamp })
426
500
  if (drag.samples.length > 6) drag.samples.shift()
427
501
  }
@@ -432,12 +506,14 @@ function initTrack(parts) {
432
506
  drag = null; delete root.dataset.dragging
433
507
  if (state !== 'horizontal') return
434
508
  suppressClick = true
435
- player.release('drag')
509
+ free = false
436
510
  if (visibleMode || player.smooth) {
437
511
  const a = samples[0], b = samples.at(-1)
438
512
  glide(cancelled ? 0 : releaseVelocity(b.x - a.x, b.t - a.t))
513
+ player.release('drag', player.pause)
439
514
  return
440
515
  }
516
+ player.release('drag', player.pause)
441
517
  const start = Math.round(left / step), near = Math.round(position())
442
518
  const flick = cancelled ? 0 : swipeStep(event.clientX - x, event.clientY - y, event.timeStamp - t)
443
519
  let to = near === start ? start + flick : near
@@ -10,7 +10,7 @@ const toggleIcons = '<svg class="system-carousel__pause" width="16" height="16"
10
10
  export function renderCarouselControls({ label = 'Carousel', autoplay = 'off', scrollbar = true, arrows = true } = {}) {
11
11
  const name = escape(label)
12
12
  const parts = []
13
- if (scrollbar) parts.push('<div class="system-carousel__scrollbar" data-scrollbar aria-hidden="true"><span></span></div>')
13
+ if (scrollbar) parts.push('<div class="system-carousel__scrollbar" data-scrollbar aria-hidden="true"><span></span><span hidden></span></div>')
14
14
  const buttons = []
15
15
  if (autoplay === 'discrete' || autoplay === 'smooth') buttons.push(`<button type="button" class="system-carousel__button system-carousel__toggle" data-autoplay-toggle aria-label="Pause automatic scrolling" hidden>${toggleIcons}</button>`)
16
16
  if (arrows) {
@@ -91,6 +91,29 @@ export function thumbGeometry(position, range, viewport) {
91
91
  return { width, left }
92
92
  }
93
93
 
94
+ /**
95
+ * Thumb for a looping strip: one window over a repeating period. `left` can run
96
+ * past 1; the part beyond the end is drawn again from the start, so the thumb
97
+ * leaves the right end and re-enters on the left as the loop continues.
98
+ */
99
+ export function loopThumbGeometry(position, period, viewport) {
100
+ if (!(period > 0)) return { width: 1, left: 0 }
101
+ return { width: Math.min(1, viewport / period), left: wrap(position, period) / period }
102
+ }
103
+
104
+ /** Speed factor for a drift easing back in after a pause: 0 to 1 over `duration` ms (smoothstep). */
105
+ export function rampFactor(elapsed, duration = 1200) {
106
+ if (!(duration > 0)) return 1
107
+ const t = Math.min(1, Math.max(0, elapsed / duration))
108
+ return t * t * (3 - 2 * t)
109
+ }
110
+
111
+ /** Delay before autoplay resumes after a reader's interaction. */
112
+ export function resumeDelay(value) {
113
+ const ms = Number(value)
114
+ return Number.isFinite(ms) ? Math.min(10000, Math.max(0, ms)) : 2000
115
+ }
116
+
94
117
  /** Momentum after a mouse drag: velocity in px/ms, decaying per 16ms frame. */
95
118
  export const MAX_GLIDE_VELOCITY = 2.5
96
119
  /** Release velocity in px/ms, capped so a fast fling travels a few cards, not the whole strip. */
@@ -15,7 +15,8 @@
15
15
  "0.2.0-alpha.14",
16
16
  "0.2.0-alpha.15",
17
17
  "0.2.0-alpha.16",
18
- "0.2.0-alpha.17"
18
+ "0.2.0-alpha.17",
19
+ "0.2.0-alpha.18"
19
20
  ],
20
21
  "status": "active"
21
22
  }