@bytebrand/fe-ui-core-autobahn 1.0.127 → 1.0.129

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.127",
3
+ "version": "1.0.129",
4
4
  "description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
5
5
  "main": "index.ts",
6
6
  "license": "UNLICENSED",
@@ -1,4 +1,22 @@
1
- import React, { useState, useRef, useEffect } from 'react';
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ // Spring constants for the track's slide animation — critically damped (damping = 2·√stiffness,
4
+ // so it approaches the target with no overshoot/bounce, matching what sampling mobile.de's own
5
+ // VDP gallery showed — see the spring loop's doc comment below) and tuned so a single ±1 move
6
+ // settles in ~800-850ms, matching mobile.de's measured settle time for the same move.
7
+ const SPRING_STIFFNESS = 64;
8
+ const SPRING_DAMPING = 16;
9
+ // Below these thresholds the spring is considered "arrived": stop stepping it (saves CPU while
10
+ // idle instead of running a rAF loop forever) and snap the last fractional pixel so the resting
11
+ // position is exact, not just "close enough". Both are small enough that the final snap is
12
+ // sub-perceptible (0.015 of a slide's width is single-digit pixels at any real card size, at a
13
+ // point where residual velocity is already crawling) — chosen by MEASURING actual settle time
14
+ // live and tuning until it landed at mobile.de's own measured ~800-850ms, rather than picking
15
+ // numbers that merely looked reasonable: 0.001/0.01 both measured over 1s to hit in practice
16
+ // (VELOCITY decays slower than position for a critically damped spring, so it — not position —
17
+ // ends up the binding constraint), well past the ~800ms the motion is already imperceptible.
18
+ const SETTLE_POS_EPS = 0.015;
19
+ const SETTLE_VEL_EPS = 0.09;
2
20
 
3
21
  export interface CardCarouselProps {
4
22
  /** ready-to-use image URLs (already resolved by the caller — no asset/window lookup here). */
@@ -34,7 +52,6 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
34
52
  fit = 'cover',
35
53
  padded = true,
36
54
  }) => {
37
- const [i, setI] = useState(0);
38
55
  const n = images.length;
39
56
  // MOBX-CQ-2: reset to the first slide whenever the caller hands us a
40
57
  // DIFFERENT gallery (this instance reused for another car) — otherwise a
@@ -44,17 +61,242 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
44
61
  // every arrow click whose onChange triggered a parent re-render (the
45
62
  // counter advanced while the track snapped back to slide 0).
46
63
  const gallerySignature = images.join('|');
47
- useEffect(() => {
48
- setI(0);
64
+ // Loop-wrap (last→first / first→last): the flex row gets one extra CLONE slide at each
65
+ // end — clone of the LAST photo before the first, clone of the FIRST photo after the last
66
+ // — so a swipe/click past either edge keeps animating in the SAME direction into a
67
+ // real-looking next photo (mobile.de's own VDP gallery does the same) instead of jump-
68
+ // cutting or animating backward through every photo in between.
69
+ const extendedImages = n > 1 ? [images[n - 1], ...images, images[0]] : images;
70
+ // `trackIndex` (0..n+1, extended-array units; real slides live at 1..n) is the spring's
71
+ // TARGET and the only piece of animation state React itself renders from — used below to
72
+ // derive `i` (0..n-1, the "real" active slide for dots/onChange/the photo-count pill).
73
+ const [trackIndex, setTrackIndex] = useState(n > 1 ? 1 : 0);
74
+ // Mirrors `trackIndex`, updated SYNCHRONOUSLY inside go()/drag itself — not via React state,
75
+ // which only becomes readable to the NEXT interaction after a render has committed. Reading
76
+ // this (not the closured `trackIndex` state, and not a functional setState updater) is what
77
+ // makes go() correct no matter how soon after the last call it runs — see go()'s doc comment.
78
+ const trackIndexRef = useRef(trackIndex);
79
+ // The ANIMATED position, same 0..n+1 units as trackIndex but a continuous float — a ref, not
80
+ // state: the spring loop below writes it up to 60×/sec straight into the DOM via trackRef,
81
+ // never through React's render cycle (far too slow for smooth 60fps motion, and it would
82
+ // fight with the loop's own direct writes if React re-rendered the same style prop).
83
+ const visualPosRef = useRef(trackIndex);
84
+ const velocityRef = useRef(0);
85
+ const trackRef = useRef<HTMLDivElement>(null);
86
+ const rafIdRef = useRef<number | null>(null);
87
+ // `n` read fresh inside the spring loop's closure would go stale if the gallery changes while
88
+ // a loop iteration is still in flight (a reused SRP card instance recycled mid-animation) —
89
+ // the gallery-reset effect below already forces a fresh start in that case, but mirroring `n`
90
+ // into a ref too costs nothing and removes the edge case outright.
91
+ const nRef = useRef(n);
92
+ nRef.current = n;
93
+
94
+ // Plain modulo, not clamped into [0, n+1] first: trackIndex is intentionally UNBOUNDED (see
95
+ // go()'s doc comment) — several rapid clicks can leave it well past a single clone-width
96
+ // before the spring loop's own rebase (below) has had a chance to catch up, and modulo maps
97
+ // ANY integer straight to the right real slide regardless, so `i`/the dots/the photo-count
98
+ // pill are correct the instant a click fires, never waiting on the animation to catch up.
99
+ const i = n > 1 ? (((trackIndex - 1) % n) + n) % n : 0;
100
+
101
+ const writeTransform = (pos: number) => {
102
+ const el = trackRef.current;
103
+ if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
104
+ };
105
+
106
+ // The spring loop: steps visualPosRef toward trackIndexRef every frame via real spring-damper
107
+ // physics (F = -k·x - c·v), not a fixed-duration easing curve. That's the whole reason this
108
+ // component moved off a CSS transition: a CSS transition RESTARTS from zero velocity on every
109
+ // RETARGET (a new target set before the old transition finishes — arrows don't debounce, so
110
+ // this happens on anything faster than one click per ~800ms), and this curve's fast start
111
+ // meant every retarget produced a visible little "kick" of re-acceleration — read as jitter,
112
+ // a wrong-direction flicker, or an uneven stutter under fast repeat clicking (confirmed live:
113
+ // sampling the track's transform frame-by-frame during a burst of real clicks showed exactly
114
+ // this). A spring's velocity is a real, continuous state variable carried across retargets —
115
+ // clicking again before the previous move settles blends smoothly into the new target instead
116
+ // of restarting. This also matches mobile.de's own gallery: sampling ITS transform earlier
117
+ // showed a front-loaded, no-overshoot, ~800-900ms settle that a fixed-duration bezier curve
118
+ // could only approximate for an ISOLATED move — under retargeting a spring is what actually
119
+ // reproduces it, not just the single-move numbers.
120
+ const stepLoop = (lastT: number) => {
121
+ rafIdRef.current = requestAnimationFrame((now) => {
122
+ const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
123
+ const n2 = nRef.current;
124
+ const accel = SPRING_STIFFNESS * (trackIndexRef.current - visualPosRef.current) - SPRING_DAMPING * velocityRef.current;
125
+ velocityRef.current += accel * dt;
126
+ visualPosRef.current += velocityRef.current * dt;
127
+
128
+ // Loop-wrap rebase: whenever the ANIMATED position itself crosses a clone boundary,
129
+ // invisibly shift BOTH trackIndex and visualPos by ∓n into the matching REAL position —
130
+ // same photo either side, so each shift is imperceptible — keeping visualPos inside the
131
+ // single-clone-wide extended array this exact frame. Triggered by visualPos crossing 0 /
132
+ // n+1, NOT by trackIndex reaching them: trackIndex (the spring's TARGET) is intentionally
133
+ // UNBOUNDED — go() below no longer clamps it — so several clicks landing faster than the
134
+ // spring can visually catch up leave it arbitrarily far past a single clone-width. An
135
+ // earlier version rebased the moment the TARGET reached a boundary, shifting visualPos by
136
+ // the same ∓n regardless of where it actually was — correct ONLY if visualPos had already
137
+ // caught up to that boundary too; under a rapid-click burst it usually hadn't (confirmed
138
+ // live: sampling mid-burst caught visualPos at position ~2.9 of 4 getting yanked to ~-0.1
139
+ // by a rebase meant for position 4), producing exactly the kind of jarring cross-carousel
140
+ // jump this whole rewrite exists to eliminate. Rebasing on visualPos's own crossing avoids
141
+ // that by construction: it only ever fires within one frame's worth of motion of the
142
+ // boundary, however far past it trackIndex itself has drifted. A `while`, not `if`, so an
143
+ // extreme burst (or a large dt from a lag spike) rebases as many times in one frame as
144
+ // visualPos has actually crossed — real motion the array can only render one clone-width
145
+ // of at a time, not something to skip over in a single jump.
146
+ while (n2 > 1 && visualPosRef.current >= n2 + 1) {
147
+ trackIndexRef.current -= n2;
148
+ visualPosRef.current -= n2;
149
+ setTrackIndex(trackIndexRef.current);
150
+ }
151
+ while (n2 > 1 && visualPosRef.current <= 0) {
152
+ trackIndexRef.current += n2;
153
+ visualPosRef.current += n2;
154
+ setTrackIndex(trackIndexRef.current);
155
+ }
156
+
157
+ writeTransform(visualPosRef.current);
158
+
159
+ const atRest = Math.abs(trackIndexRef.current - visualPosRef.current) < SETTLE_POS_EPS && Math.abs(velocityRef.current) < SETTLE_VEL_EPS;
160
+ if (atRest) {
161
+ visualPosRef.current = trackIndexRef.current;
162
+ velocityRef.current = 0;
163
+ writeTransform(visualPosRef.current);
164
+ rafIdRef.current = null;
165
+ return;
166
+ }
167
+ stepLoop(now);
168
+ });
169
+ };
170
+ const ensureLoopRunning = () => {
171
+ if (rafIdRef.current == null) stepLoop(performance.now());
172
+ };
173
+ useEffect(
174
+ () => () => {
175
+ if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current);
176
+ },
177
+ [],
178
+ );
179
+
180
+ // Gallery-swap reset — instant, no spring: jump straight to slide 0 with zero velocity so a
181
+ // reused instance (SRP list) never slides through a stale gallery's photos. useLayoutEffect
182
+ // (not useEffect) so the DOM is corrected before paint — no one-frame flash of the old
183
+ // position with the new gallery's images already swapped in underneath it.
184
+ useLayoutEffect(() => {
185
+ if (rafIdRef.current != null) {
186
+ cancelAnimationFrame(rafIdRef.current);
187
+ rafIdRef.current = null;
188
+ }
189
+ const start = n > 1 ? 1 : 0;
190
+ trackIndexRef.current = start;
191
+ visualPosRef.current = start;
192
+ velocityRef.current = 0;
193
+ setTrackIndex(start);
194
+ writeTransform(start);
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
196
  }, [gallerySignature]);
50
- const jump = (nx: number, e?: React.SyntheticEvent) => {
197
+ // Fires onChange whenever the DERIVED active slide actually changes — not on every
198
+ // trackIndex change, so the loop-wrap's snap-back (n+1→1 / 0→n, same photo either side)
199
+ // doesn't re-fire it. Reads onChange through a ref instead of listing it as a dependency:
200
+ // the VDP hands in a fresh inline arrow function every render, and depending on it directly
201
+ // would re-fire this effect (with the SAME `i`) on every unrelated parent re-render instead
202
+ // of only on an actual slide change.
203
+ const onChangeRef = useRef(onChange);
204
+ useEffect(() => {
205
+ onChangeRef.current = onChange;
206
+ });
207
+ const isFirstRender = useRef(true);
208
+ useEffect(() => {
209
+ if (isFirstRender.current) {
210
+ isFirstRender.current = false;
211
+ return;
212
+ }
213
+ onChangeRef.current && onChangeRef.current(i);
214
+ }, [i]);
215
+ // Every ±1 move goes through here, whether from a click or a released drag past the
216
+ // threshold. Reads/writes trackIndexRef synchronously (see its own doc comment above) so two
217
+ // calls landing back to back — realistic under fast repeat clicking — never race. Deliberately
218
+ // does NOT clamp/rebase trackIndex to stay within the single clone this component renders on
219
+ // either end — see the spring loop's rebase, which handles that (correctly: only once
220
+ // visualPos itself has actually caught up to a boundary, not the instant the discrete target
221
+ // passes one) — so several rapid clicks just keep pushing the target further out, and the
222
+ // spring keeps chasing it, rebasing its own coordinate system as needed along the way.
223
+ const go = (d: number, e?: React.SyntheticEvent) => {
224
+ if (n <= 1) return;
51
225
  e && e.stopPropagation();
52
- setI(nx);
53
- onChange && onChange(nx);
226
+ trackIndexRef.current += d;
227
+ setTrackIndex(trackIndexRef.current);
228
+ ensureLoopRunning();
229
+ };
230
+ // Pointer Events (not separate touch/mouse handlers) so mouse-drag on desktop works the exact
231
+ // same way touch-swipe already did — same pattern as VdpViewer's onStagePointer* handlers.
232
+ // Live-follow: visualPosRef tracks the pointer 1:1 while the gesture is in progress — same
233
+ // feel as mobile.de's / most gallery sliders' drag, instead of only reacting on release. The
234
+ // spring loop is paused for the duration (grabbing mid-flight kills any residual velocity —
235
+ // picking the track up should stop it dead, not have it keep drifting under your finger) and
236
+ // resumes on release, either toward a new target (go(), past the 40px threshold) or back to
237
+ // the unchanged one (springing back into place — also covers a plain click/tap, dx never
238
+ // moves, which still bubbles to the card as before since stopPropagation only happens in go()).
239
+ const dragRef = useRef<{ x: number; startPos: number } | null>(null);
240
+ const [isDragging, setIsDragging] = useState(false);
241
+ // Set for one tick after a drag that crossed the 40px threshold — see onClick below for why.
242
+ const justSwipedRef = useRef(false);
243
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
244
+ if (n <= 1 || (e.target as HTMLElement).closest('button')) return;
245
+ dragRef.current = { x: e.clientX, startPos: visualPosRef.current };
246
+ velocityRef.current = 0;
247
+ if (rafIdRef.current != null) {
248
+ cancelAnimationFrame(rafIdRef.current);
249
+ rafIdRef.current = null;
250
+ }
251
+ setIsDragging(true);
252
+ try {
253
+ e.currentTarget.setPointerCapture(e.pointerId);
254
+ } catch {
255
+ // same "nice to have" as VdpViewer's stage capture — safe to ignore if rejected.
256
+ }
257
+ };
258
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
259
+ const start = dragRef.current;
260
+ if (!start) return;
261
+ const width = e.currentTarget.clientWidth || 1;
262
+ visualPosRef.current = start.startPos - (e.clientX - start.x) / width;
263
+ writeTransform(visualPosRef.current);
264
+ };
265
+ const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
266
+ const start = dragRef.current;
267
+ dragRef.current = null;
268
+ setIsDragging(false);
269
+ if (!start) return;
270
+ const dx = e.clientX - start.x;
271
+ if (Math.abs(dx) > 40) {
272
+ justSwipedRef.current = true;
273
+ go(dx > 0 ? -1 : 1, e);
274
+ } else {
275
+ ensureLoopRunning(); // spring back to the unchanged trackIndex
276
+ }
277
+ };
278
+ const onPointerCancel = () => {
279
+ dragRef.current = null;
280
+ setIsDragging(false);
281
+ ensureLoopRunning();
282
+ };
283
+ // A completed drag/swipe (mousedown-move-up or touchstart-move-end, all on this same element)
284
+ // makes the browser fire a `click` right after pointerup REGARDLESS of how far the pointer
285
+ // moved in between — go()'s own e.stopPropagation() (called from onPointerUp, above) only
286
+ // stops THAT pointerup event; it can't reach forward to stop a separate click the browser
287
+ // hasn't dispatched yet. Left unguarded, that click still bubbles to a card this carousel
288
+ // lives inside (CarCard/CarRow wrap it in an onClick-to-navigate `<article>`) — swiping
289
+ // through a "similar cars" card's photos was navigating to that car's own page on release.
290
+ // Catching it here, one bubble-phase step before it would reach the card, and swallowing it
291
+ // ONLY when justSwipedRef says a real swipe (not a plain tap, which should still navigate —
292
+ // matches the click/tap-bubbles-through behavior noted in onPointerUp's own doc comment)
293
+ // just happened fixes it without the card needing to know anything about drag state itself.
294
+ const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
295
+ if (justSwipedRef.current) {
296
+ justSwipedRef.current = false;
297
+ e.stopPropagation();
298
+ }
54
299
  };
55
- const go = (d: number, e?: React.SyntheticEvent) => jump((i + d + n) % n, e);
56
- const trackRef = useRef<HTMLDivElement>(null);
57
- const tx = useRef<number | null>(null);
58
300
 
59
301
  // 'auto' mode only diverges from plain 'contain' at desktop widths — below 601px it resolves
60
302
  // to 'contain' unconditionally, so mobile's existing rendering is untouched either way.
@@ -73,33 +315,53 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
73
315
  return (
74
316
  <div
75
317
  className="cardcar"
76
- style={{ position: 'relative', height, overflow: 'hidden', borderRadius: radius }}
77
- onTouchStart={(e) => {
78
- tx.current = e.touches[0].clientX;
79
- }}
80
- onTouchEnd={(e) => {
81
- if (tx.current == null) return;
82
- const dx = e.changedTouches[0].clientX - tx.current;
83
- tx.current = null;
84
- if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
318
+ style={{
319
+ position: 'relative',
320
+ height,
321
+ overflow: 'hidden',
322
+ borderRadius: radius,
323
+ cursor: n > 1 ? (isDragging ? 'grabbing' : 'grab') : undefined,
324
+ // Without this, mobile browsers treat a horizontal touch-drag on this element as an
325
+ // ambiguous gesture and can hand it to their OWN native scroll/pan handling before our
326
+ // onPointerMove ever sees it (a pointercancel fires instead) — swipe-to-flip silently
327
+ // does nothing on a real touch device even though desktop mouse-drag (unaffected by
328
+ // touch-action) works fine. 'pan-y', not 'none': tells the browser only HORIZONTAL
329
+ // panning is ours to handle, so a mostly-vertical drag that starts on the photo still
330
+ // scrolls the page normally instead of getting swallowed. VdpViewer's full-screen stage
331
+ // already sets this (as `none` — no page scroll to preserve there); this was the one
332
+ // carousel surface that never got it.
333
+ touchAction: n > 1 ? 'pan-y' : undefined,
85
334
  }}
335
+ onPointerDown={onPointerDown}
336
+ onPointerMove={onPointerMove}
337
+ onPointerUp={onPointerUp}
338
+ onPointerCancel={onPointerCancel}
339
+ onClick={onClick}
86
340
  >
341
+ {/* transform is intentionally NOT part of this style object — it's owned entirely by
342
+ writeTransform() (the spring loop + drag handlers above), written straight to the DOM
343
+ node via trackRef. Letting React manage it here too, even with matching values, would
344
+ mean every trackIndex-driven re-render (a click, a rebase) re-applies React's own
345
+ understanding of the style — fighting the loop's per-frame writes for the same
346
+ property. The useLayoutEffect above sets the correct INITIAL value before first paint. */}
87
347
  <div
88
348
  ref={trackRef}
89
349
  style={{
90
350
  display: 'flex',
91
351
  height: '100%',
92
- transform: `translateX(-${i * 100}%)`,
93
- transition: 'transform var(--dur-3) var(--ease-emphasized)',
94
352
  }}
95
353
  >
96
- {images.map((src, k) => (
354
+ {extendedImages.map((src, k) => (
97
355
  <img
98
356
  key={k}
99
357
  src={src}
100
358
  alt=""
101
359
  loading="lazy"
102
360
  decoding="async"
361
+ // otherwise the browser's own native image-drag (ghost preview + a dragstart
362
+ // that swallows the following pointer events) hijacks the gesture instead of
363
+ // our onPointerMove/onPointerUp swipe logic above.
364
+ draggable={false}
103
365
  style={
104
366
  autoDesktop
105
367
  ? {
package/vdp/VdpViewer.tsx CHANGED
@@ -51,6 +51,24 @@ import logoUrl from '../assets/autode-logo.svg';
51
51
  const ZOOM_MIN = 1;
52
52
  const ZOOM_MAX = 3;
53
53
  const ZOOM_STEP = 0.5;
54
+ // Spring constants for the slide-to-neighbor-photo animation, shared by the desktop AND mobile
55
+ // (portrait/landscape) stages below — same physics, same tuning, just two separate refs/loops
56
+ // per stage (each only ever mounts one at a time, but the constants don't need duplicating).
57
+ // Critically damped (damping = 2·√stiffness, so it approaches with no overshoot/bounce) and
58
+ // tuned so a single ±1 move settles in ~800-850ms — matches mobile.de's own gallery, measured by
59
+ // sampling ITS transform frame-by-frame; see ui/CardCarousel.tsx's spring loop doc comment (the
60
+ // first place this pattern was built) for the full derivation and why a spring instead of a CSS
61
+ // transition at all: a fixed-duration curve restarts from zero velocity on every retarget (a new
62
+ // swipe/click before the previous one settles), which reads as jitter under fast repeat use — a
63
+ // spring's velocity carries continuously across retargets instead.
64
+ const SPRING_STIFFNESS = 64;
65
+ const SPRING_DAMPING = 16;
66
+ // Below these thresholds the spring is considered "arrived": stop stepping it (saves CPU while
67
+ // idle) and snap the last fractional pixel so the resting position is exact, not just "close
68
+ // enough" — see CardCarousel's own copy of these same two constants for the measured reasoning
69
+ // behind the specific numbers (velocity, not position, turned out to be the binding constraint).
70
+ const SETTLE_POS_EPS = 0.015;
71
+ const SETTLE_VEL_EPS = 0.09;
54
72
  const THUMB_W = 64;
55
73
  const THUMB_H = 50;
56
74
  const THUMB_GAP = 8;
@@ -540,9 +558,161 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
540
558
  const [zoom, setZoom] = useState(ZOOM_MIN);
541
559
  const [pan, setPan] = useState({ x: 0, y: 0 });
542
560
  const n = images.length;
543
- const go = (d: number) => setI((p) => (p + d + n) % n);
544
561
  const stageRef = useRef<HTMLDivElement | null>(null);
545
562
 
563
+ // Desktop layout's photo-to-photo animation — a live-drag + spring slide between the current
564
+ // photo and its neighbor, matching CardCarousel's (ui/CardCarousel.tsx) carousels elsewhere in
565
+ // the app. Before this, a desktop swipe/arrow-click just swapped `images[i]` outright (only the
566
+ // zoom `scale()` had a transition) — no slide motion at all, and mouse-drag didn't even exist
567
+ // (the old mechanism was touch-only, on the whole overlay, not this stage). Mobile (portrait/
568
+ // landscape) gets the identical treatment further below (`mobileTrack*`), folded into its
569
+ // EXISTING single pointer-drag gesture instead of a separate handler set — see that block's
570
+ // own doc comment for why. Kept as two parallel sets of refs/handlers (`desktopTrack*` /
571
+ // `mobileTrack*`), not one shared implementation: only one of the two stages is ever actually
572
+ // mounted at a time (layout is either 'desktop' or portrait/landscape), and desktop's simpler
573
+ // gesture (always a swipe, zoom never pans — see clampPan's own doc comment) doesn't need
574
+ // threading through mobile's zoom-gated pan/pinch/swipe branch, or vice versa.
575
+ //
576
+ // Structural difference from CardCarousel: that one renders ALL n images in one extended
577
+ // [clone-last, ...images, clone-first] track and walks a single persistent index through it.
578
+ // Here there's already a single `i` (0..n-1) as the source of truth (arrows/keyboard/thumbnails/
579
+ // the "all photos" sheet all set it), so instead the track always renders exactly 3 images —
580
+ // [prev, current, next], RECOMPUTED every time `i` changes — with the spring's target FIXED at
581
+ // 1 (the center slot, i.e. "showing `images[i]`") rather than walking to a new target each time.
582
+ // A ±1 step (arrow/drag-release, both go through go() below) doesn't move the target — it moves
583
+ // the WINDOW: `i` advances and visualPos is compensated by the same ∓1 so the two together
584
+ // still describe the exact same on-screen position the instant `i` changes (old-current becomes
585
+ // new-prev or new-next, so a `visualPos` that was e.g. 30% of the way toward old-next reads as
586
+ // exactly 30% of the way FROM new-prev toward new-current) — the spring then keeps animating,
587
+ // uninterrupted, toward the same fixed target 1 it always had.
588
+ const desktopTrackRef = useRef<HTMLDivElement | null>(null);
589
+ const desktopVisualPosRef = useRef(1);
590
+ const desktopVelocityRef = useRef(0);
591
+ const desktopRafIdRef = useRef<number | null>(null);
592
+ const writeDesktopTransform = (pos: number) => {
593
+ const el = desktopTrackRef.current;
594
+ if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
595
+ };
596
+ const stepDesktopLoop = (lastT: number) => {
597
+ desktopRafIdRef.current = requestAnimationFrame((now) => {
598
+ const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
599
+ const accel = SPRING_STIFFNESS * (1 - desktopVisualPosRef.current) - SPRING_DAMPING * desktopVelocityRef.current;
600
+ desktopVelocityRef.current += accel * dt;
601
+ desktopVisualPosRef.current += desktopVelocityRef.current * dt;
602
+ writeDesktopTransform(desktopVisualPosRef.current);
603
+ const atRest = Math.abs(1 - desktopVisualPosRef.current) < SETTLE_POS_EPS && Math.abs(desktopVelocityRef.current) < SETTLE_VEL_EPS;
604
+ if (atRest) {
605
+ desktopVisualPosRef.current = 1;
606
+ desktopVelocityRef.current = 0;
607
+ writeDesktopTransform(1);
608
+ desktopRafIdRef.current = null;
609
+ return;
610
+ }
611
+ stepDesktopLoop(now);
612
+ });
613
+ };
614
+ const ensureDesktopLoopRunning = () => {
615
+ if (desktopRafIdRef.current == null) stepDesktopLoop(performance.now());
616
+ };
617
+ useEffect(
618
+ () => () => {
619
+ if (desktopRafIdRef.current != null) cancelAnimationFrame(desktopRafIdRef.current);
620
+ },
621
+ [],
622
+ );
623
+ // Safety net: Chrome fully SUSPENDS requestAnimationFrame while the tab is hidden and does NOT
624
+ // auto-resume it on its own once the tab is visible again (confirmed empirically — a spring
625
+ // mid-flight when visibility is lost is left frozen exactly where it was, forever, with no
626
+ // further frames ever requested). A swipe released right before the tab backgrounds — e.g.
627
+ // switching apps mid-gesture, an everyday mobile habit — would otherwise strand the photo
628
+ // between two slides indefinitely. Nudges the loop back on if it's stuck mid-animation
629
+ // (rafIdRef null, i.e. no frame currently scheduled, AND not yet at the target) the moment the
630
+ // tab becomes visible again.
631
+ useEffect(() => {
632
+ const onVisibilityChange = () => {
633
+ if (document.visibilityState !== 'visible') return;
634
+ if (desktopRafIdRef.current == null && Math.abs(1 - desktopVisualPosRef.current) > SETTLE_POS_EPS) {
635
+ ensureDesktopLoopRunning();
636
+ }
637
+ };
638
+ document.addEventListener('visibilitychange', onVisibilityChange);
639
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
640
+ // eslint-disable-next-line react-hooks/exhaustive-deps
641
+ }, []);
642
+ // Every ±1 step — arrow click OR a released drag past the threshold (desktopOnPointerUp
643
+ // below) — goes through here; keyboard nav already calls this too (goRef.current(±1) further
644
+ // down), so it gets the same animation for free. Thumbnail clicks and the "all photos" sheet
645
+ // deliberately DON'T (they call setI(k) directly) — an arbitrary jump has no single "slide
646
+ // direction" to animate, so the effect below just snaps the track for those instead.
647
+ const changedViaGoRef = useRef(false);
648
+ const go = (d: number) => {
649
+ changedViaGoRef.current = true;
650
+ desktopVisualPosRef.current -= d;
651
+ mobileVisualPosRef.current -= d;
652
+ setI((p) => (p + d + n) % n);
653
+ if (desktopTrackRef.current) ensureDesktopLoopRunning();
654
+ if (mobileTrackRef.current) ensureMobileLoopRunning();
655
+ };
656
+ useEffect(() => {
657
+ if (changedViaGoRef.current) {
658
+ changedViaGoRef.current = false;
659
+ return;
660
+ }
661
+ if (desktopRafIdRef.current != null) {
662
+ cancelAnimationFrame(desktopRafIdRef.current);
663
+ desktopRafIdRef.current = null;
664
+ }
665
+ desktopVisualPosRef.current = 1;
666
+ desktopVelocityRef.current = 0;
667
+ writeDesktopTransform(1);
668
+ if (mobileRafIdRef.current != null) {
669
+ cancelAnimationFrame(mobileRafIdRef.current);
670
+ mobileRafIdRef.current = null;
671
+ }
672
+ mobileVisualPosRef.current = 1;
673
+ mobileVelocityRef.current = 0;
674
+ writeMobileTransform(1);
675
+ // eslint-disable-next-line react-hooks/exhaustive-deps
676
+ }, [i]);
677
+ const desktopDragRef = useRef<{ x: number; startPos: number } | null>(null);
678
+ const [isDesktopDragging, setIsDesktopDragging] = useState(false);
679
+ const desktopOnPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
680
+ if (n <= 1 || (e.target as HTMLElement).closest('button')) return;
681
+ desktopDragRef.current = { x: e.clientX, startPos: desktopVisualPosRef.current };
682
+ desktopVelocityRef.current = 0;
683
+ if (desktopRafIdRef.current != null) {
684
+ cancelAnimationFrame(desktopRafIdRef.current);
685
+ desktopRafIdRef.current = null;
686
+ }
687
+ setIsDesktopDragging(true);
688
+ try {
689
+ e.currentTarget.setPointerCapture(e.pointerId);
690
+ } catch {
691
+ // same "nice to have" as the mobile stage's own capture below — safe to ignore if rejected.
692
+ }
693
+ };
694
+ const desktopOnPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
695
+ const start = desktopDragRef.current;
696
+ if (!start) return;
697
+ const width = e.currentTarget.clientWidth || 1;
698
+ desktopVisualPosRef.current = start.startPos - (e.clientX - start.x) / width;
699
+ writeDesktopTransform(desktopVisualPosRef.current);
700
+ };
701
+ const desktopOnPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
702
+ const start = desktopDragRef.current;
703
+ desktopDragRef.current = null;
704
+ setIsDesktopDragging(false);
705
+ if (!start) return;
706
+ const dx = e.clientX - start.x;
707
+ if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
708
+ else ensureDesktopLoopRunning(); // spring back to the unchanged photo
709
+ };
710
+ const desktopOnPointerCancel = () => {
711
+ desktopDragRef.current = null;
712
+ setIsDesktopDragging(false);
713
+ ensureDesktopLoopRunning();
714
+ };
715
+
546
716
  // "Alle Fotos" bottom sheet (mobile only — see PhotoSheet above). Opened from the "+N" thumbnail
547
717
  // tile in portrait/landscape; picking a photo in it just reuses `i`/`setI`, so the counter and
548
718
  // main image update for free. Read via a ref inside the keydown handler below (registered once
@@ -560,9 +730,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
560
730
  // open. Both 'resize' (fires on rotation in every current mobile browser) and 'orientationchange'
561
731
  // are listened for: some browsers deliver 'orientationchange' a tick before window.innerWidth
562
732
  // has actually updated to the new orientation's value, so that handler re-reads it on the next
563
- // frame rather than trusting the value at event-fire time. `isMobile` (both portrait and
564
- // landscape) is kept as a derived boolean for the one spot that still needs it: the desktop-only
565
- // whole-overlay touch-swipe guard below.
733
+ // frame rather than trusting the value at event-fire time.
566
734
  const [layout, setLayout] = useState<Layout>(() => (typeof window !== 'undefined' ? layoutForWidth(window.innerWidth) : 'portrait'));
567
735
  useEffect(() => {
568
736
  const onResize = (): void => setLayout(layoutForWidth(window.innerWidth));
@@ -576,7 +744,6 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
576
744
  window.removeEventListener('orientationchange', onOrientationChange);
577
745
  };
578
746
  }, []);
579
- const isMobile = layout !== 'desktop';
580
747
 
581
748
  // Reset zoom (and pan) on every slide change — a lingering zoom/pan from the previous photo
582
749
  // reads as a stuck/broken control on the new one.
@@ -634,15 +801,63 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
634
801
  const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
635
802
  const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
636
803
 
637
- // Desktop swipe-to-navigate — the original behaviour, unchanged: a plain touch delta on the
638
- // whole overlay, no pan.
639
- const tx = useRef<number | null>(null);
640
-
641
- // Mobile: one pointer-drag gesture serves two purposes depending on zoom level: at 1x it's a
642
- // left/right swipe to change photo (mouse OR touch); zoomed in, it pans the enlarged photo
643
- // around instead (that's the whole point of zooming in — being able to look at every part of
644
- // it, not just the centered crop). Pointer Events cover mouse + touch + pen in one handler set.
645
- const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number } } | null>(null);
804
+ // Mobile (portrait/landscape) photo-to-photo animation — same live-drag + spring slide as the
805
+ // desktop stage above (see its own doc comment for the full "why a spring" reasoning), just
806
+ // sharing this stage's SINGLE pointer-drag gesture with pan-when-zoomed and pinch instead of
807
+ // having its own separate handlers: at zoom<=1 a drag is a swipe (this loop), zoomed in it
808
+ // pans the enlarged photo around instead (the existing setPan branch, untouched) — the two
809
+ // are mutually exclusive per-gesture (checked at move/up time), so there's nothing to
810
+ // reconcile between them beyond that branch.
811
+ const mobileTrackRef = useRef<HTMLDivElement | null>(null);
812
+ const mobileVisualPosRef = useRef(1);
813
+ const mobileVelocityRef = useRef(0);
814
+ const mobileRafIdRef = useRef<number | null>(null);
815
+ const writeMobileTransform = (pos: number) => {
816
+ const el = mobileTrackRef.current;
817
+ if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
818
+ };
819
+ const stepMobileLoop = (lastT: number) => {
820
+ mobileRafIdRef.current = requestAnimationFrame((now) => {
821
+ const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
822
+ const accel = SPRING_STIFFNESS * (1 - mobileVisualPosRef.current) - SPRING_DAMPING * mobileVelocityRef.current;
823
+ mobileVelocityRef.current += accel * dt;
824
+ mobileVisualPosRef.current += mobileVelocityRef.current * dt;
825
+ writeMobileTransform(mobileVisualPosRef.current);
826
+ const atRest = Math.abs(1 - mobileVisualPosRef.current) < SETTLE_POS_EPS && Math.abs(mobileVelocityRef.current) < SETTLE_VEL_EPS;
827
+ if (atRest) {
828
+ mobileVisualPosRef.current = 1;
829
+ mobileVelocityRef.current = 0;
830
+ writeMobileTransform(1);
831
+ mobileRafIdRef.current = null;
832
+ return;
833
+ }
834
+ stepMobileLoop(now);
835
+ });
836
+ };
837
+ const ensureMobileLoopRunning = () => {
838
+ if (mobileRafIdRef.current == null) stepMobileLoop(performance.now());
839
+ };
840
+ useEffect(
841
+ () => () => {
842
+ if (mobileRafIdRef.current != null) cancelAnimationFrame(mobileRafIdRef.current);
843
+ },
844
+ [],
845
+ );
846
+ // Same rAF-suspended-while-hidden safety net as the desktop stage's own copy above — see its
847
+ // doc comment for why (a swipe released right before the tab backgrounds, e.g. switching apps
848
+ // mid-gesture, would otherwise strand the photo frozen mid-slide forever).
849
+ useEffect(() => {
850
+ const onVisibilityChange = () => {
851
+ if (document.visibilityState !== 'visible') return;
852
+ if (mobileRafIdRef.current == null && Math.abs(1 - mobileVisualPosRef.current) > SETTLE_POS_EPS) {
853
+ ensureMobileLoopRunning();
854
+ }
855
+ };
856
+ document.addEventListener('visibilitychange', onVisibilityChange);
857
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
858
+ // eslint-disable-next-line react-hooks/exhaustive-deps
859
+ }, []);
860
+ const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number }; startPos: number } | null>(null);
646
861
  // Pinch-to-zoom: every active pointer is tracked by id so a genuine 2nd finger landing can be
647
862
  // told apart from the single-pointer pan/swipe drag above and switch into pinch mode instead.
648
863
  // The ratio between the CURRENT and STARTING distance between the two touch points scales zoom
@@ -672,7 +887,14 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
672
887
  return;
673
888
  }
674
889
  if (pointersRef.current.size === 1) {
675
- dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
890
+ dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan, startPos: mobileVisualPosRef.current };
891
+ if (zoom <= ZOOM_MIN) {
892
+ mobileVelocityRef.current = 0;
893
+ if (mobileRafIdRef.current != null) {
894
+ cancelAnimationFrame(mobileRafIdRef.current);
895
+ mobileRafIdRef.current = null;
896
+ }
897
+ }
676
898
  }
677
899
  };
678
900
  const onStagePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -703,7 +925,13 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
703
925
  return;
704
926
  }
705
927
  const start = dragRef.current;
706
- if (!start || zoom <= ZOOM_MIN) return;
928
+ if (!start) return;
929
+ if (zoom <= ZOOM_MIN) {
930
+ const width = e.currentTarget.clientWidth || 1;
931
+ mobileVisualPosRef.current = start.startPos - (e.clientX - start.x) / width;
932
+ writeMobileTransform(mobileVisualPosRef.current);
933
+ return;
934
+ }
707
935
  const dx = e.clientX - start.x;
708
936
  const dy = e.clientY - start.y;
709
937
  setPan(clampPan({ x: start.panStart.x + dx, y: start.panStart.y + dy }, zoom));
@@ -720,6 +948,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
720
948
  if (zoom <= ZOOM_MIN) {
721
949
  const dx = e.clientX - start.x;
722
950
  if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
951
+ else ensureMobileLoopRunning(); // spring back to the unchanged photo
723
952
  }
724
953
  };
725
954
 
@@ -773,16 +1002,6 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
773
1002
  display: 'flex',
774
1003
  flexDirection: 'column',
775
1004
  }}
776
- onTouchStart={(e) => {
777
- if (isMobile) return; // mobile pans/swipes via the stage's own pointer handlers instead.
778
- tx.current = e.touches[0].clientX;
779
- }}
780
- onTouchEnd={(e) => {
781
- if (isMobile || tx.current == null) return;
782
- const dx = e.changedTouches[0].clientX - tx.current;
783
- tx.current = null;
784
- if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
785
- }}
786
1005
  >
787
1006
  {layout === 'portrait' ? (
788
1007
  <>
@@ -836,25 +1055,38 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
836
1055
  onPointerUp={onStagePointerUp}
837
1056
  onPointerCancel={onStagePointerUp}
838
1057
  >
839
- <img
840
- src={images[i]}
841
- alt=""
842
- draggable={false}
843
- style={{
844
- position: 'absolute',
845
- inset: 0,
846
- width: '100%',
847
- height: '100%',
848
- objectFit: 'contain',
849
- transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
850
- // pinchRef too, not just dragRef — without it every pinch pointermove still
851
- // kicked off a 200ms eased transition toward a target that moves again before
852
- // it finishes, which reads as the image flickering/jittering bigger-smaller
853
- // instead of tracking the fingers 1:1 (drag already had this right).
854
- transition: (dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
855
- cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
856
- }}
857
- />
1058
+ {/* mobileTrackRef's transform is owned entirely by writeMobileTransform() (the
1059
+ spring loop + the swipe branch of onStagePointerMove above) — same reasoning as
1060
+ the desktop track: React re-applying a matching value here on every `i`-driven
1061
+ re-render would fight the loop's own per-frame writes to this same property. */}
1062
+ <div ref={mobileTrackRef} style={{ position: 'absolute', inset: 0, display: 'flex', height: '100%' }}>
1063
+ {[images[(i - 1 + n) % n], images[i], images[(i + 1) % n]].map((src, k) => (
1064
+ <img
1065
+ key={k}
1066
+ src={src}
1067
+ alt=""
1068
+ draggable={false}
1069
+ style={{
1070
+ flex: '0 0 100%',
1071
+ width: '100%',
1072
+ height: '100%',
1073
+ objectFit: 'contain',
1074
+ // Zoom/pan only ever apply to the CENTER slot (k===1, the actually-
1075
+ // displayed photo) — prev/next are only ever visible mid-slide, and both
1076
+ // reset on every `i` change anyway (see the effect above), so by the time
1077
+ // either could become the new center they're already back to 1x/centered.
1078
+ transform: k === 1 ? `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` : undefined,
1079
+ // pinchRef too, not just dragRef — without it every pinch pointermove still
1080
+ // kicked off a 200ms eased transition toward a target that moves again before
1081
+ // it finishes, which reads as the image flickering/jittering bigger-smaller
1082
+ // instead of tracking the fingers 1:1 (drag already had this right).
1083
+ transition:
1084
+ k === 1 ? ((dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)') : undefined,
1085
+ cursor: k === 1 && zoom > ZOOM_MIN ? 'grab' : undefined,
1086
+ }}
1087
+ />
1088
+ ))}
1089
+ </div>
858
1090
 
859
1091
  <div style={{ ...pillStyle, left: 12 }} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
860
1092
  <span className="num viewer-counter-num">
@@ -1118,25 +1350,34 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1118
1350
  onPointerUp={onStagePointerUp}
1119
1351
  onPointerCancel={onStagePointerUp}
1120
1352
  >
1121
- <img
1122
- src={images[i]}
1123
- alt=""
1124
- draggable={false}
1125
- style={{
1126
- position: 'absolute',
1127
- inset: 0,
1128
- width: '100%',
1129
- height: '100%',
1130
- objectFit: 'cover',
1131
- transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
1132
- // pinchRef too, not just dragRef — without it every pinch pointermove still
1133
- // kicked off a 200ms eased transition toward a target that moves again before
1134
- // it finishes, which reads as the image flickering/jittering bigger-smaller
1135
- // instead of tracking the fingers 1:1 (drag already had this right).
1136
- transition: (dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
1137
- cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
1138
- }}
1139
- />
1353
+ {/* mobileTrackRef is SHARED with portrait above (same ref, same spring loop, same
1354
+ onStagePointer* handlers) — only one of portrait/landscape is ever mounted at a
1355
+ time, so there's no conflict; see portrait's copy of this comment for why
1356
+ transform isn't set here directly. */}
1357
+ <div ref={mobileTrackRef} style={{ position: 'absolute', inset: 0, display: 'flex', height: '100%' }}>
1358
+ {[images[(i - 1 + n) % n], images[i], images[(i + 1) % n]].map((src, k) => (
1359
+ <img
1360
+ key={k}
1361
+ src={src}
1362
+ alt=""
1363
+ draggable={false}
1364
+ style={{
1365
+ flex: '0 0 100%',
1366
+ width: '100%',
1367
+ height: '100%',
1368
+ objectFit: 'cover',
1369
+ transform: k === 1 ? `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` : undefined,
1370
+ // pinchRef too, not just dragRef — without it every pinch pointermove still
1371
+ // kicked off a 200ms eased transition toward a target that moves again before
1372
+ // it finishes, which reads as the image flickering/jittering bigger-smaller
1373
+ // instead of tracking the fingers 1:1 (drag already had this right).
1374
+ transition:
1375
+ k === 1 ? ((dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)') : undefined,
1376
+ cursor: k === 1 && zoom > ZOOM_MIN ? 'grab' : undefined,
1377
+ }}
1378
+ />
1379
+ ))}
1380
+ </div>
1140
1381
  </div>
1141
1382
 
1142
1383
  {/* logo — Figma node 424:9330 "auto-de_RGB (2) 1": the actual asset there is the
@@ -1529,20 +1770,52 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1529
1770
  <IconButton name="x" size={24} className="vdp-viewer-light" label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
1530
1771
  </div>
1531
1772
  </div>
1532
- <div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>
1533
- <img
1534
- src={images[i]}
1535
- alt=""
1536
- style={{
1537
- position: 'absolute',
1538
- inset: 0,
1539
- width: '100%',
1540
- height: '100%',
1541
- objectFit: 'contain',
1542
- transform: `scale(${zoom})`,
1543
- transition: 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
1544
- }}
1545
- />
1773
+ <div
1774
+ style={{
1775
+ flex: 1,
1776
+ position: 'relative',
1777
+ minHeight: 0,
1778
+ background: '#fff',
1779
+ overflow: 'hidden',
1780
+ cursor: n > 1 ? (isDesktopDragging ? 'grabbing' : 'grab') : undefined,
1781
+ // Without this, a touch-capable desktop (a tablet reporting >860px, or Chrome's
1782
+ // own device toolbar) hands a horizontal drag to the browser's native scroll/pan
1783
+ // handling before onPointerMove ever sees it — same fix as CardCarousel's
1784
+ // touch-action (see its own doc comment) applied here for the same reason.
1785
+ touchAction: n > 1 ? 'pan-y' : undefined,
1786
+ }}
1787
+ onPointerDown={desktopOnPointerDown}
1788
+ onPointerMove={desktopOnPointerMove}
1789
+ onPointerUp={desktopOnPointerUp}
1790
+ onPointerCancel={desktopOnPointerCancel}
1791
+ >
1792
+ {/* transform is NOT set here — desktopTrackRef's owned entirely by
1793
+ writeDesktopTransform() (the spring loop + drag handlers above), written
1794
+ straight to the DOM node. Same reasoning as CardCarousel's own track: letting
1795
+ React re-apply a matching value here too would fight the loop's per-frame
1796
+ writes for the same property. */}
1797
+ <div ref={desktopTrackRef} style={{ display: 'flex', height: '100%' }}>
1798
+ {[images[(i - 1 + n) % n], images[i], images[(i + 1) % n]].map((src, k) => (
1799
+ <img
1800
+ key={k}
1801
+ src={src}
1802
+ alt=""
1803
+ draggable={false}
1804
+ style={{
1805
+ flex: '0 0 100%',
1806
+ width: '100%',
1807
+ height: '100%',
1808
+ objectFit: 'contain',
1809
+ // Zoom only ever applies to the CENTER slot (the actually-displayed photo,
1810
+ // k===1) — prev/next are only ever visible mid-slide, and zoom always
1811
+ // resets to 1x on every `i` change anyway (see the effect above), so by the
1812
+ // time either of them could become the new center they're back to unzoomed.
1813
+ transform: k === 1 ? `scale(${zoom})` : undefined,
1814
+ transition: k === 1 ? 'transform var(--dur-2, 200ms) var(--ease-standard, ease)' : undefined,
1815
+ }}
1816
+ />
1817
+ ))}
1818
+ </div>
1546
1819
  <button
1547
1820
  className="car-arrow car-arrow--l hide-mobile"
1548
1821
  style={{ opacity: 1 }}