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

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/docs/guides/extensions.md +6 -6
  4. package/docs/guides/graph-workspace.md +19 -7
  5. package/docs/guides/upgrade.md +24 -0
  6. package/docs/reference/graph-workspace.md +4 -4
  7. package/docs/reference/portable-presentation.md +9 -0
  8. package/docs/reference/ui-primitives.md +116 -0
  9. package/docs/start/independent-start.md +2 -2
  10. package/fixtures/templates/local-library.v1.json +3 -1
  11. package/package.json +3 -1
  12. package/src/extensions/project.mjs +5 -5
  13. package/src/graph-workspace/browser/index.mjs +1 -1
  14. package/src/graph-workspace/browser/{constellation.mjs → map.mjs} +7 -2
  15. package/src/graph-workspace/browser/styles.mjs +1 -1
  16. package/src/graph-workspace/browser/workbench.mjs +2 -2
  17. package/src/graph-workspace/browser/workspace.mjs +53 -51
  18. package/src/graph-workspace/disclosure.mjs +1 -1
  19. package/src/graph-workspace/geometry.mjs +1 -1
  20. package/src/graph-workspace/index.mjs +2 -2
  21. package/src/graph-workspace/recipes.mjs +24 -12
  22. package/src/graph-workspace/scale.mjs +5 -2
  23. package/src/graph-workspace/theme.mjs +2 -2
  24. package/src/ui/primitives/browser.mjs +6 -0
  25. package/src/ui/primitives/carousel.mjs +551 -0
  26. package/src/ui/primitives/disclosure.mjs +50 -0
  27. package/src/ui/primitives/index.mjs +7 -0
  28. package/src/ui/primitives/motion.mjs +50 -0
  29. package/src/ui/primitives/render.mjs +22 -0
  30. package/src/ui/primitives/scroll-lock.mjs +47 -0
  31. package/src/ui/primitives/state.mjs +130 -0
  32. package/src/ui/primitives/styles.mjs +127 -0
  33. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Carousel enhancer (browser). Progressive enhancement over plain HTML: without
3
+ * script every slide is a native horizontal scroller (track) or a stacked list
4
+ * (fade). See docs/reference/ui-primitives.md for the markup contract.
5
+ *
6
+ * Track carousels scroll natively (CSS scroll snap), so touch, trackpad, wheel
7
+ * and keyboard keep the platform's physics. Script adds seamless loops over
8
+ * inert clones, mouse drag (with momentum for multi-card strips), a draggable
9
+ * scroll bar, arrows, pages or thumbnails, a live status, height that follows
10
+ * the current slide, receding neighbours, a still row when every card fits,
11
+ * and discrete or smooth autoplay with a pause control.
12
+ */
13
+ import {
14
+ arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, nearestCopy, releaseVelocity,
15
+ pageSize, recede, rowFit, scrubPosition, slideWindow, swipeStep, thumbGeometry, wrap,
16
+ } from './state.mjs'
17
+ import { animationAllowed, onMotionChange } from './motion.mjs'
18
+
19
+ const q = (root, selectors) => root.querySelector(selectors)
20
+ const qa = (root, selectors) => [...root.querySelectorAll(selectors)]
21
+ const finePointer = () => matchMedia('(hover: hover) and (pointer: fine)').matches
22
+
23
+ // Move keyboard focus before its slide becomes inert or hidden.
24
+ function focusAvailable(candidates, reveal = false) {
25
+ const target = candidates.find(element => element && !element.matches(':disabled') && !element.closest('[inert], [hidden]') && element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden')
26
+ target?.focus({ preventScroll: !reveal })
27
+ return !!target && document.activeElement === target
28
+ }
29
+ function revealControlFocus(controls) {
30
+ const focused = document.activeElement
31
+ if (focused instanceof HTMLElement && controls?.contains(focused) && focused.getClientRects().length) focused.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' })
32
+ }
33
+ function preserveSlideFocus(slides, shown, control) {
34
+ if (!slides.some(slide => slide !== shown && slide.contains(document.activeElement))) return
35
+ const controls = control?.closest('[data-controls]')
36
+ focusAvailable([control, ...(controls ? controls.querySelectorAll('button') : [])])
37
+ }
38
+
39
+ /** Enhance every `[data-carousel]` under `root`. Safe to call more than once. */
40
+ export function enhanceCarousels(root = document) {
41
+ return qa(root, '[data-carousel]').map(enhanceCarousel).filter(Boolean)
42
+ }
43
+
44
+ export function enhanceCarousel(root) {
45
+ if (root.dataset.ready !== undefined) return null
46
+ const track = q(root, '[data-carousel-track], .carousel__track')
47
+ if (!track) return null
48
+ // Slides are the track's children; `data-slide` narrows that when a track holds other elements.
49
+ const children = [...track.children].filter(element => !element.hasAttribute('data-clone'))
50
+ const slides = children.some(element => element.hasAttribute('data-slide')) ? children.filter(element => element.hasAttribute('data-slide')) : children
51
+ const parts = {
52
+ root, track, slides, n: slides.length,
53
+ label: root.getAttribute('aria-label') ?? 'Carousel',
54
+ viewport: q(root, '[data-carousel-viewport], .carousel__viewport') || track,
55
+ controls: q(root, '[data-controls]'),
56
+ back: q(root, '[data-prev]'),
57
+ next: q(root, '[data-next]'),
58
+ pages: qa(root, '[data-page]'),
59
+ status: q(root, '[data-status]'),
60
+ count: q(root, '[data-count]'),
61
+ scrollbar: q(root, '[data-scrollbar]'),
62
+ toggle: q(root, '[data-autoplay-toggle]'),
63
+ autoplay: autoplayConfig({ mode: root.dataset.autoplay, interval: root.dataset.interval, speed: root.dataset.speed }),
64
+ }
65
+ if (parts.n < 2) { if (parts.controls) parts.controls.hidden = true; root.dataset.ready = ''; return null }
66
+ slides.forEach((slide, i) => {
67
+ slide.setAttribute('role', 'group')
68
+ slide.setAttribute('aria-roledescription', 'slide')
69
+ if (!slide.hasAttribute('aria-label')) slide.setAttribute('aria-label', `${i + 1} of ${parts.n}`)
70
+ })
71
+ root.querySelectorAll('img, a').forEach(element => element.setAttribute('draggable', 'false'))
72
+ // Thumbnail pages without their own image show the slide's image.
73
+ parts.pages.forEach((page, i) => {
74
+ if (!page.hasAttribute('data-thumb') || page.querySelector('img')) return
75
+ const img = slides[i]?.querySelector('img')
76
+ const set = () => img?.currentSrc && page.style.setProperty('--thumb', `url("${img.currentSrc}")`)
77
+ img?.complete ? set() : img?.addEventListener('load', set, { once: true })
78
+ })
79
+ return root.dataset.carousel === 'fade' ? initFade(parts) : initTrack(parts)
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ autoplay */
83
+
84
+ function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopDrift, canRun }) {
85
+ const reasons = new Set()
86
+ let userPaused = root.dataset.autoplayPaused !== undefined
87
+ let onScreen = true
88
+ let timer = 0
89
+ const enabled = () => autoplay.mode !== 'off' && animationAllowed() && canRun()
90
+ const running = () => enabled() && !userPaused && !reasons.size && onScreen
91
+ const stop = () => { clearTimeout(timer); timer = 0; stopDrift() }
92
+ const start = () => {
93
+ stop()
94
+ if (!running()) return
95
+ if (autoplay.mode === 'discrete') timer = setTimeout(() => { advance(); start() }, autoplay.interval)
96
+ else startDrift()
97
+ }
98
+ const sync = () => {
99
+ root.dataset.autoplayState = enabled() ? (userPaused ? 'paused' : reasons.size ? 'held' : 'playing') : 'off'
100
+ if (!toggle) return
101
+ toggle.hidden = !enabled()
102
+ toggle.setAttribute('aria-label', userPaused ? 'Play automatic scrolling' : 'Pause automatic scrolling')
103
+ toggle.dataset.state = userPaused ? 'paused' : 'playing'
104
+ }
105
+ const hold = reason => { reasons.add(reason); stop(); sync() }
106
+ const release = (reason, delay = 0) => {
107
+ const go = () => { reasons.delete(reason); start(); sync() }
108
+ delay ? setTimeout(go, delay) : go()
109
+ }
110
+ if (autoplay.mode !== 'off') {
111
+ 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) }
118
+ root.addEventListener('pointerup', lift)
119
+ root.addEventListener('pointercancel', lift)
120
+ let wheelTimer = 0
121
+ root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'), 2500) }, { passive: true })
122
+ onMotionChange(() => { start(); sync() })
123
+ if ('IntersectionObserver' in window) new IntersectionObserver(entries => { onScreen = entries.some(entry => entry.isIntersecting); start(); sync() }, { threshold: 0.25 }).observe(root)
124
+ }
125
+ return { start, stop, hold, release, sync, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
126
+ }
127
+
128
+ /* ------------------------------------------------------------- scroll bar */
129
+
130
+ function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
131
+ const thumb = scrollbar?.querySelector('span, [data-scrollbar-thumb]')
132
+ if (!scrollbar || !thumb) return () => {}
133
+ scrollbar.addEventListener('pointerdown', event => {
134
+ if (event.button > 0) return
135
+ event.preventDefault()
136
+ 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
140
+ scrollbar.setPointerCapture(event.pointerId)
141
+ root.dataset.scrubbing = ''
142
+ begin()
143
+ const move = x => scrubTo(scrubPosition(x, rail.left, rail.width, box.width, grab, geometry().range))
144
+ move(event.clientX)
145
+ const onMove = moveEvent => move(moveEvent.clientX)
146
+ const onUp = () => { scrollbar.removeEventListener('pointermove', onMove); delete root.dataset.scrubbing; end() }
147
+ scrollbar.addEventListener('pointermove', onMove)
148
+ scrollbar.addEventListener('pointerup', onUp, { once: true })
149
+ scrollbar.addEventListener('pointercancel', onUp, { once: true })
150
+ })
151
+ 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}%`
156
+ scrollbar.hidden = range <= 1
157
+ }
158
+ }
159
+
160
+ /* ------------------------------------------------------------------ track */
161
+
162
+ function initTrack(parts) {
163
+ const { root, viewport, track, slides, n, label, controls, back, next, pages, status, count } = parts
164
+ const loop = root.hasAttribute('data-loop')
165
+ const peek = root.hasAttribute('data-peek') || root.classList.contains('carousel--peek')
166
+ // select="current": one slide is current and the rest are inert (slideshows).
167
+ // select="visible": every card on screen stays interactive (product strips).
168
+ const visibleMode = root.dataset.select === 'visible'
169
+ const pageStep = (root.dataset.step || (visibleMode ? 'page' : 'item')) === 'page'
170
+ // fit="current": the strip is as tall as the slide in view, blending between heights as they pass.
171
+ const fitCurrent = root.dataset.fit === 'current'
172
+ const rowMin = Number(root.dataset.rowMin) || 400
173
+ let sets = 0
174
+ let items = slides
175
+ let step = 0, current = -1, target = null, announce = false, row = false, timer = 0, realigned = false
176
+ let home = 0, intent = false, drifting = false, gliding = 0, scrubbing = false
177
+ let heights = [], padY = 0, padTop = 0
178
+ let drag = null, suppressClick = false
179
+
180
+ const buildClones = perSide => {
181
+ track.querySelectorAll('[data-clone]').forEach(clone => clone.remove())
182
+ sets = loop ? loopSets(n, perSide) : 0
183
+ if (sets) {
184
+ const clone = slide => {
185
+ const copy = slide.cloneNode(true)
186
+ copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach(element => element.removeAttribute('id'))
187
+ copy.dataset.clone = ''; copy.inert = true; copy.setAttribute('aria-hidden', 'true')
188
+ return copy
189
+ }
190
+ const copies = () => Array.from({ length: sets }, () => slides.map(clone)).flat()
191
+ track.prepend(...copies()); track.append(...copies())
192
+ }
193
+ items = [...track.children]
194
+ }
195
+ const first = () => sets * n
196
+ const position = () => (step ? viewport.scrollLeft / step : 0)
197
+ const scrollToIndex = (index, smooth) => viewport.scrollTo({ left: index * step, behavior: smooth && animationAllowed() ? 'smooth' : 'instant' })
198
+ const perPage = () => (pageStep ? arrowStep(pageSize(viewport.clientWidth, step), n, loop) : 1)
199
+ const lastIndex = () => (step ? Math.max(0, Math.round((viewport.scrollWidth - viewport.clientWidth) / step)) : 0)
200
+
201
+ const fitHeight = pos => {
202
+ if (!fitCurrent || row || !heights.length) return
203
+ const a = Math.floor(pos), h = i => heights[((i % n) + n) % n]
204
+ viewport.style.height = `${h(a) + (h(a + 1) - h(a)) * (pos - a) + padY}px`
205
+ }
206
+ const placeArrows = () => {
207
+ const middle = fitCurrent && heights.length ? padTop + Math.min(...heights) / 2 : viewport.offsetHeight / 2
208
+ root.style.setProperty('--mid', `${viewport.offsetTop + middle}px`)
209
+ }
210
+ const measureHeights = () => {
211
+ if (!fitCurrent) return
212
+ const style = getComputedStyle(viewport)
213
+ padTop = parseFloat(style.paddingTop); padY = padTop + parseFloat(style.paddingBottom)
214
+ heights = slides.map(slide => slide.offsetHeight)
215
+ }
216
+
217
+ const geometry = () => {
218
+ if (sets) {
219
+ const period = n * step
220
+ return { position: wrap(viewport.scrollLeft - first() * step, period), range: Math.max(0, period - viewport.clientWidth), viewport: viewport.clientWidth }
221
+ }
222
+ return { position: viewport.scrollLeft, range: Math.max(0, viewport.scrollWidth - viewport.clientWidth), viewport: viewport.clientWidth }
223
+ }
224
+ const paintScrollbar = bindScrollbar(parts, {
225
+ geometry,
226
+ scrubTo: offset => { viewport.scrollLeft = (sets ? first() * step : 0) + offset },
227
+ begin: () => { stopGlide(); scrubbing = true; intent = true; viewport.style.scrollSnapType = 'none'; player.hold('scrub') },
228
+ end: () => { scrubbing = false; intent = true; settleSoon(); player.release('scrub') },
229
+ })
230
+
231
+ const paint = () => {
232
+ if (row || !step) return
233
+ const pos = position(), c = Math.max(0, Math.min(items.length - 1, Math.round(pos)))
234
+ if (peek) items.forEach((item, i) => item.style.setProperty('--d', recede(pos, i).toFixed(3)))
235
+ fitHeight(pos)
236
+ paintScrollbar()
237
+ if (c === current) return
238
+ current = c
239
+ if (!loop && back && next) { back.disabled = c <= 0; next.disabled = c >= lastIndex() }
240
+ if (!visibleMode) preserveSlideFocus(items, items[c], pages[c % n])
241
+ items.forEach((item, i) => {
242
+ const shown = i === c
243
+ item.classList.toggle('is-current', shown)
244
+ if (!visibleMode && !item.hasAttribute('data-clone')) { item.inert = !shown; item.toggleAttribute('aria-hidden', !shown) }
245
+ })
246
+ pages.forEach((page, i) => page.setAttribute('aria-current', String(i === c % n)))
247
+ if (count) count.textContent = `${(c % n) + 1} / ${n}`
248
+ }
249
+
250
+ // Once still: step back onto the real slide showing the same content (identical, so invisible).
251
+ const settle = () => {
252
+ if (row || !step || drag || gliding || scrubbing) return
253
+ if (drifting) return
254
+ if (player.smooth && player.active()) return
255
+ if (!intent) {
256
+ if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
257
+ const goal = (first() + home) * step
258
+ if (Math.abs(viewport.scrollLeft - Math.min(goal, viewport.scrollWidth - viewport.clientWidth)) > 1) scrollToIndex(first() + home, false)
259
+ paint(); return
260
+ }
261
+ const c = Math.round(position())
262
+ if (!player.smooth && !realigned && Math.abs(viewport.scrollLeft - c * step) > 2 && c < lastIndex()) {
263
+ realigned = true; viewport.style.removeProperty('scroll-snap-type'); scrollToIndex(c, true)
264
+ if (!animationAllowed()) settle()
265
+ return
266
+ }
267
+ realigned = false
268
+ if (sets && (c < first() || c >= first() + n)) viewport.scrollTo({ left: viewport.scrollLeft + (homeIndex(c, n, sets) - c) * step, behavior: 'instant' })
269
+ home = ((Math.round(position()) % n) + n) % n
270
+ if (!sets) home = Math.round(position())
271
+ intent = false; target = null
272
+ if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
273
+ paint()
274
+ if (announce && status) { status.textContent = `${label}: ${(current % n) + 1} of ${n}`; announce = false }
275
+ }
276
+ const settleSoon = () => { if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) } else requestAnimationFrame(() => settle()) }
277
+
278
+ const go = (delta, { user = true } = {}) => {
279
+ if (row || !step) return
280
+ stopGlide()
281
+ intent = true
282
+ let base = target ?? Math.round(position())
283
+ if (sets) {
284
+ const equivalent = homeIndex(base, n, sets)
285
+ if (equivalent !== base) { viewport.scrollTo({ left: viewport.scrollLeft + (equivalent - base) * step, behavior: 'instant' }); base = equivalent }
286
+ } else if (loop && delta > 0 && base >= lastIndex()) {
287
+ delta = -base // a loop too short for clones still wraps
288
+ } else if (loop && delta < 0 && base <= 0) {
289
+ delta = lastIndex()
290
+ }
291
+ const to = sets ? base + delta : Math.max(0, Math.min(lastIndex(), base + delta))
292
+ target = to; announce = user
293
+ scrollToIndex(to, true)
294
+ }
295
+ const goTo = index => go(sets ? nearestCopy(target ?? Math.round(position()), index, n) : index - (target ?? Math.round(position())))
296
+
297
+ const measure = () => {
298
+ const focusedSlide = slides.findIndex(slide => slide.contains(document.activeElement))
299
+ const keep = row && focusedSlide >= 0 ? focusedSlide : home
300
+ home = keep
301
+ const controlFocused = controls?.contains(document.activeElement)
302
+ const focusedPage = pages.findIndex(page => page.contains(document.activeElement))
303
+ root.style.removeProperty('--slide-fit'); delete root.dataset.row
304
+ const gap = parseFloat(getComputedStyle(track).columnGap) || 0
305
+ const fit = peek ? rowFit(n, slides[0].offsetWidth, gap, root.clientWidth, rowMin) : 0
306
+ row = fit > 0
307
+ viewport.style.removeProperty('height')
308
+ if (row) {
309
+ root.dataset.row = ''; root.style.setProperty('--slide-fit', `${fit}px`)
310
+ slides.forEach(slide => { slide.inert = false; slide.removeAttribute('aria-hidden'); slide.classList.remove('is-current'); slide.style.removeProperty('--d') })
311
+ if (controlFocused) {
312
+ const shown = slides[focusedPage >= 0 ? focusedPage : keep]
313
+ if (!focusAvailable([...shown.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')], true)) { root.tabIndex = -1; root.focus() }
314
+ }
315
+ if (controls) controls.hidden = true
316
+ current = -1
317
+ return
318
+ }
319
+ // Fractional widths matter: offsetLeft rounds, and the error grows per card.
320
+ const width = parseFloat(getComputedStyle(slides[0]).width)
321
+ step = width + gap
322
+ root.style.setProperty('--slide-px', `${width}px`)
323
+ const fitsAll = !sets && viewport.scrollWidth - viewport.clientWidth <= 1
324
+ if (loop) {
325
+ const needed = pageSize(viewport.clientWidth, step) + (peek ? 4 : 1)
326
+ if (!sets || loopSets(n, needed) !== sets) buildClones(needed)
327
+ // A loop that cannot fill the screen with one set falls back to plain wrapping arrows.
328
+ if (n * step < viewport.clientWidth) { track.querySelectorAll('[data-clone]').forEach(clone => clone.remove()); sets = 0; items = slides }
329
+ }
330
+ root.dataset.overflow = String(!(fitsAll || (!sets && viewport.scrollWidth - viewport.clientWidth <= 1)))
331
+ if (controls) controls.hidden = root.dataset.overflow === 'false'
332
+ measureHeights()
333
+ current = -1; target = null
334
+ scrollToIndex(first() + keep, false)
335
+ paint()
336
+ placeArrows()
337
+ revealControlFocus(controls)
338
+ player.sync()
339
+ }
340
+
341
+ // Momentum for multi-card strips; slideshows step one slide per swipe instead.
342
+ function stopGlide() { cancelAnimationFrame(gliding); gliding = 0 }
343
+ const glide = velocity => {
344
+ if (!animationAllowed() || Math.abs(velocity) < 0.05) { settle(); return }
345
+ let last = performance.now(), pos = viewport.scrollLeft
346
+ const frame = now => {
347
+ const next = glideStep(pos, velocity, now - last)
348
+ last = now; pos = next.position; velocity = next.velocity
349
+ viewport.scrollLeft = pos
350
+ 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() }
353
+ }
354
+ gliding = requestAnimationFrame(frame)
355
+ }
356
+
357
+ // Smooth autoplay keeps its own sub-pixel position so slow speeds never stall on rounding.
358
+ let driftFrame = 0
359
+ const player = createAutoplay(parts, {
360
+ advance: () => go(perPage(), { user: false }),
361
+ canRun: () => !row && root.dataset.overflow !== 'false',
362
+ startDrift: () => {
363
+ drifting = true
364
+ viewport.style.scrollSnapType = 'none'
365
+ let pos = viewport.scrollLeft, last = 0
366
+ const frame = now => {
367
+ 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)
370
+ viewport.scrollLeft = pos
371
+ }
372
+ last = now
373
+ driftFrame = requestAnimationFrame(frame)
374
+ }
375
+ driftFrame = requestAnimationFrame(frame)
376
+ },
377
+ stopDrift: () => {
378
+ if (!driftFrame) return
379
+ cancelAnimationFrame(driftFrame); driftFrame = 0; drifting = false
380
+ home = ((Math.round(position()) - first()) % n + n) % n
381
+ },
382
+ })
383
+ if (player.smooth) root.dataset.drift = ''
384
+
385
+ viewport.addEventListener('scroll', () => {
386
+ requestAnimationFrame(paint)
387
+ if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) }
388
+ }, { passive: true })
389
+ viewport.addEventListener('scrollend', settle)
390
+ const user = () => { intent = true; stopGlide() }
391
+ for (const type of ['pointerdown', 'touchstart', 'wheel', 'keydown']) viewport.addEventListener(type, user, { passive: true })
392
+
393
+ back?.addEventListener('click', () => { go(-perPage()); player.restart() })
394
+ next?.addEventListener('click', () => { go(perPage()); player.restart() })
395
+ pages.forEach((page, i) => page.addEventListener('click', () => { goTo(i); player.restart() }))
396
+ root.addEventListener('keydown', event => {
397
+ if (row || event.altKey || event.ctrlKey || event.metaKey) return
398
+ if (event.target.closest?.('input, select, textarea, [contenteditable]')) return
399
+ const keys = { ArrowLeft: () => go(-1), ArrowRight: () => go(1), Home: () => goTo(0), End: () => goTo(n - 1) }
400
+ if (!keys[event.key]) return
401
+ event.preventDefault(); keys[event.key]()
402
+ })
403
+
404
+ // Mouse drag (touch and trackpads already scroll natively): follow the pointer, then settle.
405
+ root.addEventListener('pointerdown', event => {
406
+ if (row || event.pointerType !== 'mouse' || event.button !== 0 || event.target.closest('button, [data-scrollbar], input, select, textarea')) return
407
+ if (!viewport.contains(event.target)) return
408
+ stopGlide()
409
+ drag = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp, left: viewport.scrollLeft, state: 'pending', samples: [{ x: event.clientX, t: event.timeStamp }] }
410
+ suppressClick = false
411
+ })
412
+ root.addEventListener('pointermove', event => {
413
+ if (!drag || event.pointerId !== drag.id) return
414
+ const dx = event.clientX - drag.x, dy = event.clientY - drag.y
415
+ if (drag.state === 'pending') {
416
+ drag.state = dragIntent(dx, dy)
417
+ if (drag.state === 'vertical') { drag = null; return }
418
+ if (drag.state === 'horizontal') {
419
+ root.setPointerCapture(event.pointerId); root.dataset.dragging = ''
420
+ viewport.style.scrollSnapType = 'none'; intent = true; player.hold('drag')
421
+ }
422
+ }
423
+ if (drag.state === 'horizontal') {
424
+ viewport.scrollLeft = drag.left - dx
425
+ drag.samples.push({ x: event.clientX, t: event.timeStamp })
426
+ if (drag.samples.length > 6) drag.samples.shift()
427
+ }
428
+ })
429
+ const release = (event, cancelled) => {
430
+ if (!drag || event.pointerId !== drag.id) return
431
+ const { x, y, t, left, state, samples } = drag
432
+ drag = null; delete root.dataset.dragging
433
+ if (state !== 'horizontal') return
434
+ suppressClick = true
435
+ player.release('drag')
436
+ if (visibleMode || player.smooth) {
437
+ const a = samples[0], b = samples.at(-1)
438
+ glide(cancelled ? 0 : releaseVelocity(b.x - a.x, b.t - a.t))
439
+ return
440
+ }
441
+ const start = Math.round(left / step), near = Math.round(position())
442
+ const flick = cancelled ? 0 : swipeStep(event.clientX - x, event.clientY - y, event.timeStamp - t)
443
+ let to = near === start ? start + flick : near
444
+ if (!sets) to = Math.max(0, Math.min(lastIndex(), to))
445
+ announce = to !== start; target = to
446
+ scrollToIndex(to, true)
447
+ if (Math.abs(viewport.scrollLeft - to * step) < 1) settle()
448
+ }
449
+ root.addEventListener('pointerup', event => release(event, false))
450
+ root.addEventListener('pointercancel', event => release(event, true))
451
+ root.addEventListener('dragstart', event => event.preventDefault())
452
+ root.addEventListener('click', event => {
453
+ if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false; return }
454
+ // A click on a receding neighbour brings it to the centre instead of following its link.
455
+ if (visibleMode || !peek || row || !step || event.detail === 0 || !viewport.contains(event.target)) return
456
+ const box = viewport.getBoundingClientRect()
457
+ const offset = Math.round((event.clientX - (box.left + box.width / 2)) / step)
458
+ if (offset) { event.preventDefault(); event.stopPropagation(); go(offset) }
459
+ }, true)
460
+
461
+ let lastWidth = 0
462
+ new ResizeObserver(() => requestAnimationFrame(() => { const w = root.clientWidth; if (Math.abs(w - lastWidth) > 0.5) { lastWidth = w; measure() } })).observe(root)
463
+ if (fitCurrent) {
464
+ const watch = new ResizeObserver(() => requestAnimationFrame(() => { if (row || !step) return; measureHeights(); fitHeight(position()); placeArrows() }))
465
+ slides.forEach(slide => watch.observe(slide))
466
+ }
467
+ root.dataset.ready = ''
468
+ measure()
469
+ player.start()
470
+ player.sync()
471
+ return { root, go, goTo, pause: () => player.hold('api'), play: () => player.release('api') }
472
+ }
473
+
474
+ /* ------------------------------------------------------------------- fade */
475
+
476
+ function initFade(parts) {
477
+ const { root, viewport, slides, n, label, controls, back, next, pages, status, count } = parts
478
+ const loop = root.hasAttribute('data-loop')
479
+ const fit = root.dataset.fit === 'current'
480
+ let active = 0
481
+ const fitHeight = () => { if (fit) viewport.style.height = `${slides[active].offsetHeight}px` }
482
+ const placeArrows = () => {
483
+ const height = fit ? Math.min(...slides.map(slide => slide.offsetHeight)) : viewport.offsetHeight
484
+ root.style.setProperty('--mid', `${viewport.offsetTop + height / 2}px`)
485
+ }
486
+ let lastWidth = root.clientWidth
487
+ const watch = new ResizeObserver(() => requestAnimationFrame(() => {
488
+ const width = root.clientWidth, resized = Math.abs(width - lastWidth) > 0.5
489
+ lastWidth = width
490
+ fitHeight(); placeArrows()
491
+ if (resized) revealControlFocus(controls)
492
+ }))
493
+ watch.observe(root); slides.forEach(slide => watch.observe(slide))
494
+ const render = announce => {
495
+ const state = slideWindow(active, n, 1, loop)
496
+ active = state.start
497
+ if (back) back.disabled = state.previousDisabled
498
+ if (next) next.disabled = state.nextDisabled
499
+ preserveSlideFocus(slides, slides[active], pages[active])
500
+ slides.forEach((slide, i) => { const shown = i === active; slide.classList.toggle('is-current', shown); slide.inert = !shown })
501
+ pages.forEach((page, i) => page.setAttribute('aria-current', String(i === active)))
502
+ if (count) count.textContent = `${active + 1} / ${n}`
503
+ fitHeight()
504
+ if (announce && status) status.textContent = `${label}: ${active + 1} of ${n}`
505
+ root.dispatchEvent(new CustomEvent('carousel:change', { detail: { index: active } }))
506
+ }
507
+ const go = (targetIndex, announce = true) => { active = loop ? ((targetIndex % n) + n) % n : Math.max(0, Math.min(n - 1, targetIndex)); render(announce) }
508
+ const player = createAutoplay(parts, { advance: () => go(active + 1, false), canRun: () => true, startDrift: () => {}, stopDrift: () => {} })
509
+
510
+ back?.addEventListener('click', () => { go(active - 1); player.restart() })
511
+ next?.addEventListener('click', () => { go(active + 1); player.restart() })
512
+ pages.forEach((page, i) => page.addEventListener('click', () => { go(i); player.restart() }))
513
+ root.addEventListener('keydown', event => {
514
+ if (event.altKey || event.ctrlKey || event.metaKey) return
515
+ const keys = { ArrowLeft: () => go(active - 1), ArrowRight: () => go(active + 1), Home: () => go(0), End: () => go(n - 1) }
516
+ if (!keys[event.key]) return
517
+ event.preventDefault(); keys[event.key]()
518
+ })
519
+
520
+ // Swipes: a horizontal gesture changes slide; a vertical one stays a page scroll.
521
+ let drag = null, suppressClick = false
522
+ root.addEventListener('pointerdown', event => {
523
+ if (!event.isPrimary || event.button > 0 || event.target.closest('button')) return
524
+ drag = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp, state: 'pending' }
525
+ suppressClick = false
526
+ })
527
+ root.addEventListener('pointermove', event => {
528
+ if (!drag || event.pointerId !== drag.id || drag.state !== 'pending') return
529
+ drag.state = dragIntent(event.clientX - drag.x, event.clientY - drag.y)
530
+ if (drag.state === 'vertical') drag = null
531
+ else if (drag.state === 'horizontal') { root.setPointerCapture(event.pointerId); root.dataset.dragging = '' }
532
+ })
533
+ const release = (event, cancelled) => {
534
+ if (!drag || event.pointerId !== drag.id) return
535
+ const horizontal = drag.state === 'horizontal', delta = cancelled ? 0 : swipeStep(event.clientX - drag.x, event.clientY - drag.y, event.timeStamp - drag.t)
536
+ drag = null; delete root.dataset.dragging
537
+ if (!horizontal) return
538
+ suppressClick = true
539
+ if (delta) { go(active + delta); player.restart() }
540
+ }
541
+ root.addEventListener('pointerup', event => release(event, false))
542
+ root.addEventListener('pointercancel', event => release(event, true))
543
+ root.addEventListener('click', event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }, true)
544
+
545
+ if (controls) controls.hidden = false
546
+ root.dataset.ready = ''
547
+ render(false)
548
+ placeArrows()
549
+ player.start(); player.sync()
550
+ return { root, go, pause: () => player.hold('api'), play: () => player.release('api') }
551
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Native <details> disclosures with interruptible open and close motion
3
+ * (browser). Keyboard and no-script behaviour stay native. data-state follows
4
+ * the reader's intent at once, so icons turn with the click while the panel
5
+ * is still moving. Reduced motion opens and closes instantly.
6
+ */
7
+ import { animationAllowed } from './motion.mjs'
8
+
9
+ const PROPERTIES = ['height', 'paddingTop', 'paddingBottom', 'marginTop', 'marginBottom', 'opacity']
10
+
11
+ export function enhanceDisclosures(root = document, { openMs = 360, closeMs = 280 } = {}) {
12
+ root.querySelectorAll('details').forEach(details => {
13
+ const summary = details.querySelector(':scope > summary')
14
+ const content = summary?.nextElementSibling
15
+ if (!summary || !content || details.dataset.disclosureReady !== undefined) return
16
+ details.dataset.disclosureReady = ''
17
+ details.dataset.state = details.open ? 'open' : 'closed'
18
+ if (!content.animate) return
19
+ let animation = null
20
+ let opening = details.open
21
+ const values = () => {
22
+ const style = getComputedStyle(content)
23
+ return Object.fromEntries(PROPERTIES.map(property => [property, style[property]]))
24
+ }
25
+ const clear = () => { content.style.overflow = ''; content.style.boxSizing = '' }
26
+ summary.addEventListener('click', event => {
27
+ event.preventDefault()
28
+ if (!animation) opening = details.open
29
+ opening = !opening
30
+ details.dataset.state = opening ? 'open' : 'closed'
31
+ if (!opening && content.contains(document.activeElement)) summary.focus()
32
+ const current = animation ? values() : null
33
+ animation?.cancel()
34
+ animation = null
35
+ if (!animationAllowed()) { details.open = opening; clear(); return }
36
+ details.open = true
37
+ content.style.overflow = 'hidden'
38
+ content.style.boxSizing = 'border-box'
39
+ const expanded = values()
40
+ const collapsed = Object.fromEntries(PROPERTIES.map(property => [property, property === 'opacity' ? '0' : '0px']))
41
+ animation = content.animate([current || (opening ? collapsed : expanded), opening ? expanded : collapsed], {
42
+ duration: opening ? openMs : closeMs,
43
+ easing: opening ? 'cubic-bezier(.22, 1, .36, 1)' : 'cubic-bezier(.4, 0, 1, 1)',
44
+ fill: 'both',
45
+ })
46
+ animation.onfinish = () => { details.open = opening; animation.cancel(); animation = null; clear() }
47
+ })
48
+ details.addEventListener('toggle', () => { if (!animation) details.dataset.state = details.open ? 'open' : 'closed' })
49
+ })
50
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Atelier UI primitives (Node-safe): pure state, styles and markup
3
+ * helpers. Browser enhancers live in ./browser.mjs (@mnstry/atelier/ui/primitives/browser).
4
+ */
5
+ export * from './state.mjs'
6
+ export { carouselStyles } from './styles.mjs'
7
+ export { renderCarouselControls } from './render.mjs'
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One owner for motion (browser): the reduced-motion preference and hidden tabs
3
+ * stop every effect started through these helpers.
4
+ */
5
+ import { motionAllowed } from './state.mjs'
6
+
7
+ const listeners = new Set()
8
+ const running = new Set()
9
+ let reduced = null
10
+ let started = false
11
+
12
+ function preference() {
13
+ if (!reduced && typeof matchMedia === 'function') reduced = matchMedia('(prefers-reduced-motion: reduce)')
14
+ return reduced
15
+ }
16
+
17
+ /** True when animation may run now. */
18
+ export function animationAllowed() {
19
+ return motionAllowed(Boolean(preference()?.matches), false, typeof document !== 'undefined' && document.hidden)
20
+ }
21
+
22
+ /** Call `listener` whenever the motion preference or tab visibility changes. */
23
+ export function onMotionChange(listener) {
24
+ listeners.add(listener)
25
+ startMotionWatch()
26
+ return () => listeners.delete(listener)
27
+ }
28
+
29
+ /** A Web Animation owned by the motion layer: skipped when motion is off, finished when it turns off. */
30
+ export function animate(element, frames, options) {
31
+ if (!animationAllowed() || !element.animate) return null
32
+ const animation = element.animate(frames, options)
33
+ running.add(animation)
34
+ animation.finished.catch(() => {}).finally(() => running.delete(animation))
35
+ return animation
36
+ }
37
+
38
+ function sync() {
39
+ document.documentElement.classList.toggle('motion-paused', Boolean(preference()?.matches))
40
+ if (!animationAllowed()) running.forEach(animation => animation.finish())
41
+ listeners.forEach(listener => listener())
42
+ }
43
+
44
+ export function startMotionWatch() {
45
+ if (started || typeof document === 'undefined') return
46
+ started = true
47
+ preference()?.addEventListener('change', sync)
48
+ document.addEventListener('visibilitychange', sync)
49
+ sync()
50
+ }