From ca20f579c48e7c49a44e8771754f00839b53d99f Mon Sep 17 00:00:00 2001 From: Meng Chan Date: Thu, 13 Aug 2026 12:26:47 +0800 Subject: [PATCH 1/4] fix(text): keep motion-ticker in place across a hover pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pause-on-hover made the marquee snap back to its starting position on mouse leave, and stopped it dead instead of decelerating (#2). onEnter paused the animation before lerpRate(0) ran, so the ramp rendered nothing and the deceleration was never visible. The ramp then drove ctrls.speed down to ~0.003 against the already-paused animation. On resume, MainThreadAnimation.play() rebases startTime as `now - holdTime` while tick() reads back `(timestamp - startTime) * speed`, so the elapsed time is multiplied by the current speed — at 0.003 that collapses ~0.7s of progress to ~0.002s, restarting the loop. Let the ramp own the stop: onEnter only calls lerpRate(0), which pauses through the playback controller once the rate reaches MIN_RATE, so the animation stays live while it decelerates and its time stays meaningful. Resuming now restores ctrls.time explicitly after play(), the same way onResize() already does, and the rate never goes below MIN_RATE. The keyboard path had the inverted order too — there pause() cancelled the rate ramp outright, so Space stopped the ticker dead. It now shares the same path as hover. Co-Authored-By: Claude Opus 5 --- src/text/motion-ticker/motion-ticker.test.ts | 92 ++++++++++++++++++++ src/text/motion-ticker/motion-ticker.ts | 47 +++++----- 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/src/text/motion-ticker/motion-ticker.test.ts b/src/text/motion-ticker/motion-ticker.test.ts index 58634b7..4f92473 100644 --- a/src/text/motion-ticker/motion-ticker.test.ts +++ b/src/text/motion-ticker/motion-ticker.test.ts @@ -11,6 +11,37 @@ const ticker = (extra = '') => >`, ) as Promise +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Long enough for the rate ramp to bottom out, so a resume happens from a + * fully stopped ticker rather than from one still coasting near full speed. + */ +const HOVER_DWELL = 900 + +/** Track offset in px — negative and decreasing while scrolling left. */ +const trackX = (el: MotionTicker) => { + const track = el.querySelector('div') + if (!track) throw new Error('ticker has no track') + return new DOMMatrix(getComputedStyle(track).transform).m41 +} + +/** Poll until `predicate` holds, so tests never depend on a fixed ramp length. */ +async function until(predicate: () => boolean, timeout = 3000) { + const deadline = performance.now() + timeout + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out waiting for condition') + await sleep(16) + } +} + +/** A ticker that has been scrolling long enough to be well away from x: 0. */ +async function scrollingTicker() { + const el = await ticker() + await until(() => trackX(el) < -20) + return el +} + describe('motion-ticker', () => { beforeEach(() => { stubReducedMotion(false) @@ -59,4 +90,65 @@ describe('motion-ticker', () => { el.finish() expect(el.playState).toBe('finished') }) + + it('keeps scrolling while it decelerates on hover', async () => { + const el = await scrollingTicker() + const atHover = trackX(el) + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(80) + expect(trackX(el)).toBeLessThan(atHover) + }) + + it('holds its position for as long as the pointer stays', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + await sleep(120) + expect(trackX(el)).toBe(stopped) + }) + + it('resumes from where it stopped when the pointer leaves', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + const stopped = trackX(el) + + el.dispatchEvent(new MouseEvent('mouseleave')) + await sleep(120) + + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThanOrEqual(stopped) + expect(trackX(el)).toBeGreaterThan(stopped - 20) + }) + + it('decelerates and resumes in place when toggled by keyboard', async () => { + const el = await scrollingTicker() + const atPress = trackX(el) + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(80) + expect(trackX(el)).toBeLessThan(atPress) + + await sleep(HOVER_DWELL) + const stopped = trackX(el) + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(120) + + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThanOrEqual(stopped) + expect(trackX(el)).toBeGreaterThan(stopped - 20) + }) + + it('does not pause on hover when pause-on-hover is false', async () => { + const el = (await fixture( + html`OneTwoThree`, + )) as MotionTicker + await until(() => trackX(el) < -20) + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(120) + expect(el.playState).toBe('running') + }) }) diff --git a/src/text/motion-ticker/motion-ticker.ts b/src/text/motion-ticker/motion-ticker.ts index d37a526..5db5836 100644 --- a/src/text/motion-ticker/motion-ticker.ts +++ b/src/text/motion-ticker/motion-ticker.ts @@ -5,6 +5,8 @@ import type { MotionTickerProps, TickerDirection } from './motion-ticker.types.j export type { MotionTickerProps, TickerDirection } from './motion-ticker.types.js' +const MIN_RATE = 0.05 + /** * Horizontal auto-scrolling ticker / marquee. Duplicates children to create a * seamless infinite loop. Supports pause-on-hover, keyboard pause (Space/Enter), @@ -52,7 +54,6 @@ export class MotionTicker extends Controllable(HTMLElement) { private targetRate = 1 private currentRate = 1 private rateRaf: number | null = null - private paused = false private waveRaf: number | null = null private wavePhase = 0 @@ -73,7 +74,7 @@ export class MotionTicker extends Controllable(HTMLElement) { } }, resume: () => { - this.ctrls?.play() + this.resumeCtrls() if (this.wave) this.startWave() if (this.currentRate < 1) this.lerpRate(1) }, @@ -234,7 +235,6 @@ export class MotionTicker extends Controllable(HTMLElement) { this.ctrls?.stop() this.currentRate = 1 this.targetRate = 1 - this.paused = false this.ctrls = animate( this.track, @@ -288,22 +288,35 @@ export class MotionTicker extends Controllable(HTMLElement) { this.ctrls.speed = this.currentRate } + private resumeCtrls() { + if (!this.ctrls) return + const time = this.ctrls.time + this.currentRate = Math.max(this.currentRate, MIN_RATE) + this.ctrls.speed = this.currentRate + this.ctrls.play() + this.ctrls.time = time + } + private lerpRate(target: number) { this.targetRate = target if (this.rateRaf !== null) return const step = () => { - if (!this.ctrls) return + if (!this.ctrls) { + this.rateRaf = null + return + } const diff = this.targetRate - this.currentRate - if (Math.abs(diff) < 0.003) { - this.currentRate = this.targetRate - if (this.currentRate === 0) { - this.ctrls.pause() - this.paused = true + const stopped = this.targetRate === 0 && this.currentRate <= MIN_RATE + if (stopped || Math.abs(diff) < 0.003) { + this.rateRaf = null + if (this.targetRate === 0) { + this.currentRate = 0 + this.pause() } else { + this.currentRate = this.targetRate this.ctrls.speed = this.currentRate } - this.rateRaf = null return } this.currentRate += diff * 0.1 @@ -314,16 +327,11 @@ export class MotionTicker extends Controllable(HTMLElement) { } private onEnter = () => { - if (this.playState === 'running') this.pause() this.lerpRate(0) } private onLeave = () => { - if (this.paused) { - this.ctrls?.play() - this.paused = false - } - this.lerpRate(1) if (this.playState === 'paused') void this.play() + this.lerpRate(1) } private keyboardPaused = false @@ -333,16 +341,11 @@ export class MotionTicker extends Controllable(HTMLElement) { e.preventDefault() if (this.keyboardPaused) { this.keyboardPaused = false - if (this.paused) { - this.ctrls?.play() - this.paused = false - } - this.lerpRate(1) if (this.playState === 'paused') void this.play() + this.lerpRate(1) } else { this.keyboardPaused = true this.lerpRate(0) - if (this.playState === 'running') this.pause() } } From a40a93dd0a5c4ad2865be5837e0f013aca463f1c Mon Sep 17 00:00:00 2001 From: Meng Chan Date: Sun, 16 Aug 2026 02:26:03 +0800 Subject: [PATCH 2/4] fix(text): carry the pause state and position across marquee rebuilds A live attribute change or resize rebuilt the ticker animation running at full speed, so a hover-paused ticker started scrolling while still reporting 'paused'. Rebuilds from both paths now go through one rebuildMarquee() that floors ctrls.speed at MIN_RATE, holds the fresh animation when the controller is paused, flushes motion's async keyframe resolver so the carried-over time survives the first frame, and derives progress from the outgoing animation's own duration so the position no longer scales with a speed change. Co-Authored-By: Claude Fable 5 --- src/text/motion-ticker/motion-ticker.test.ts | 34 +++++++++++++++ src/text/motion-ticker/motion-ticker.ts | 44 ++++++++++++-------- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/text/motion-ticker/motion-ticker.test.ts b/src/text/motion-ticker/motion-ticker.test.ts index 4f92473..7d2e09b 100644 --- a/src/text/motion-ticker/motion-ticker.test.ts +++ b/src/text/motion-ticker/motion-ticker.test.ts @@ -140,6 +140,40 @@ describe('motion-ticker', () => { expect(trackX(el)).toBeGreaterThan(stopped - 20) }) + it('stays parked in place when a live attribute changes while hover-paused', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + + el.setAttribute('speed', '30') + await sleep(120) + + expect(el.playState).toBe('paused') + const parked = trackX(el) + expect(Math.abs(parked - stopped)).toBeLessThan(2) + await sleep(120) + expect(trackX(el)).toBe(parked) + + el.dispatchEvent(new MouseEvent('mouseleave')) + await sleep(120) + + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThanOrEqual(parked) + expect(trackX(el)).toBeGreaterThan(parked - 20) + }) + + it('scrolls on without a jump across a live attribute change while running', async () => { + const el = await scrollingTicker() + const atChange = trackX(el) + el.setAttribute('speed', '30') + await sleep(120) + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThan(atChange) + expect(trackX(el)).toBeGreaterThan(atChange - 20) + }) + it('does not pause on hover when pause-on-hover is false', async () => { const el = (await fixture( html` Date: Sun, 16 Aug 2026 02:37:06 +0800 Subject: [PATCH 3/4] fix(text): make a live gap change actually re-style the track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap styles were written once in build(), so changing the gap attribute only altered the animation math while the rendered columnGap and marginRight kept their original values — and the wrap width was measured against the new gap, leaving the loop seam off by the delta. Gap styling now lives in one applyGap() that build, startMarquee and rebuildMarquee all run before measuring. Co-Authored-By: Claude Fable 5 --- src/text/motion-ticker/motion-ticker.test.ts | 13 +++++++++++++ src/text/motion-ticker/motion-ticker.ts | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/text/motion-ticker/motion-ticker.test.ts b/src/text/motion-ticker/motion-ticker.test.ts index 7d2e09b..d17201c 100644 --- a/src/text/motion-ticker/motion-ticker.test.ts +++ b/src/text/motion-ticker/motion-ticker.test.ts @@ -174,6 +174,19 @@ describe('motion-ticker', () => { expect(trackX(el)).toBeGreaterThan(atChange - 20) }) + it('re-applies the gap to the track when the attribute changes', async () => { + const el = await scrollingTicker() + const setA = el.querySelector('div > div') as HTMLElement + const setB = setA.nextElementSibling as HTMLElement + expect(getComputedStyle(setA).columnGap).toBe('32px') + + el.setAttribute('gap', '64') + + expect(getComputedStyle(setA).columnGap).toBe('64px') + expect(getComputedStyle(setA).marginRight).toBe('64px') + expect(getComputedStyle(setB).columnGap).toBe('64px') + }) + it('does not pause on hover when pause-on-hover is false', async () => { const el = (await fixture( html` { this.fillSet(items) @@ -216,6 +213,7 @@ export class MotionTicker extends Controllable(HTMLElement) { private startMarquee() { if (!this.track || !this.setA) return + this.applyGap() const w = this.setA.offsetWidth + this.gap if (!w) { requestAnimationFrame(() => this.startMarquee()) @@ -270,9 +268,19 @@ export class MotionTicker extends Controllable(HTMLElement) { * at MIN_RATE like `resumeCtrls()`, since a running animation at speed 0 * reads back `time` as 0 and would lose the position on the next resume. */ + private applyGap() { + if (!this.setA) return + const gap = `${this.gap}px` + this.setA.style.columnGap = gap + this.setA.style.marginRight = gap + const setB = this.setA.nextElementSibling as HTMLElement | null + if (setB) setB.style.columnGap = gap + } + private rebuildMarquee() { if (this.playState !== 'running' && this.playState !== 'paused') return if (!this.ctrls || !this.setA || !this.track) return + this.applyGap() const w = this.setA.offsetWidth + this.gap if (!w) return // Progress is a fraction of the *outgoing* animation's own duration, so From 71b6ceb946097db11ce6f8cb30db2be806d4c6bb Mon Sep 17 00:00:00 2001 From: Meng Chan Date: Sun, 16 Aug 2026 02:51:17 +0800 Subject: [PATCH 4/4] fix(text): survive marquee rebuilds with wave, direction, keyboard and fill intact Four defects on the same rebuild path, found by auditing every observed attribute against every playback state: - The wave loop captured speed, direction, stride and item positions at start and was never refreshed, so any live attribute change or resize desynced the wave from the scroll. Geometry now lives in refreshWave(), run on every rebuild; while paused it only re-measures and the resume path restarts the loop. - A direction flip mapped the progress fraction onto mirrored keyframes and teleported the track. The rebuild now derives its time from the rendered offset, which holds the position under any change of duration or direction. - A pointer or focus leaving resumed a ticker the user had paused with Space, and left the flag inverted so the next press did nothing. onLeave now respects the keyboard pause. - fillSet() only ran at build, so a container that grew later was left with a gap after the duplicated sets. Rebuilds now top the track up, and fillSet skips the setB rebuild when nothing changed. Co-Authored-By: Claude Fable 5 --- src/text/motion-ticker/motion-ticker.test.ts | 66 ++++++++++++++++++++ src/text/motion-ticker/motion-ticker.ts | 56 ++++++++++++----- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/text/motion-ticker/motion-ticker.test.ts b/src/text/motion-ticker/motion-ticker.test.ts index d17201c..e985b86 100644 --- a/src/text/motion-ticker/motion-ticker.test.ts +++ b/src/text/motion-ticker/motion-ticker.test.ts @@ -174,6 +174,72 @@ describe('motion-ticker', () => { expect(trackX(el)).toBeGreaterThan(atChange - 20) }) + it('keeps its rendered position when direction flips', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + + el.setAttribute('direction', 'right') + await sleep(120) + + expect(el.playState).toBe('paused') + expect(Math.abs(trackX(el) - stopped)).toBeLessThan(2) + }) + + it('keeps a keyboard pause across a pointer visit', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(80) + el.dispatchEvent(new MouseEvent('mouseleave')) + await sleep(200) + + expect(el.playState).toBe('paused') + expect(trackX(el)).toBe(stopped) + + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(120) + expect(el.playState).toBe('running') + }) + + it('tops the track back up when the container grows', async () => { + const el = await scrollingTicker() + const setA = el.querySelector('div > div') as HTMLElement + const grownTo = setA.offsetWidth + 200 + el.style.width = `${grownTo}px` + await until(() => setA.offsetWidth >= grownTo) + }) + + it('re-times the wave when speed changes', async () => { + const el = (await fixture( + html`OneTwoThree`, + )) as MotionTicker + await until(() => trackX(el) < -5) + + // Wave period is wave-length / speed: 10s before, 0.5s after. Total + // vertical travel over ~1.1s tells the two apart with a wide margin. + el.setAttribute('speed', '600') + const item = el.querySelector('div > div > span') as HTMLElement + const y = () => new DOMMatrix(getComputedStyle(item).transform).m42 + let travel = 0 + let last = y() + for (let i = 0; i < 14; i++) { + await sleep(80) + const cur = y() + travel += Math.abs(cur - last) + last = cur + } + expect(travel).toBeGreaterThan(25) + }) + it('re-applies the gap to the track when the attribute changes', async () => { const el = await scrollingTicker() const setA = el.querySelector('div > div') as HTMLElement diff --git a/src/text/motion-ticker/motion-ticker.ts b/src/text/motion-ticker/motion-ticker.ts index eb21a41..f953aec 100644 --- a/src/text/motion-ticker/motion-ticker.ts +++ b/src/text/motion-ticker/motion-ticker.ts @@ -59,6 +59,7 @@ export class MotionTicker extends Controllable(HTMLElement) { private wavePhase = 0 private itemLocalPositions: number[] = [] private resizeObserver: ResizeObserver | null = null + private originalItems: HTMLElement[] = [] playback: PlaybackController = new PlaybackController(this, { start: () => { @@ -157,6 +158,7 @@ export class MotionTicker extends Controllable(HTMLElement) { private build() { const items = Array.from(this.children) as HTMLElement[] if (!items.length) return + this.originalItems = items const track = node('div', { display: 'flex', @@ -201,11 +203,13 @@ export class MotionTicker extends Controllable(HTMLElement) { const containerW = this.offsetWidth if (!containerW) return let safety = 50 + let grown = false while (this.setA.offsetWidth < containerW && safety-- > 0) { originals.forEach((c) => this.setA!.appendChild(c.cloneNode(true))) + grown = true } const setB = this.setA.nextElementSibling as HTMLElement | null - if (setB) { + if (setB && (grown || setB.childElementCount !== this.setA.childElementCount)) { setB.replaceChildren() Array.from(this.setA.children).forEach((c) => setB.appendChild(c.cloneNode(true))) } @@ -245,17 +249,24 @@ export class MotionTicker extends Controllable(HTMLElement) { this.addEventListener('blur', this.onLeave) this.addEventListener('keydown', this.onKeyDown) + this.refreshWave() + } + + private refreshWave() { + if (!this.setA) return this.style.overflow = this.wave ? 'visible' : 'hidden' - if (this.wave) { - const setALeft = this.setA.getBoundingClientRect().left - this.itemLocalPositions = (Array.from(this.setA.children) as HTMLElement[]).map((el) => { - const r = el.getBoundingClientRect() - return r.left + r.width / 2 - setALeft - }) - this.startWave() - } else { + if (!this.wave) { this.stopWave() + return } + const setALeft = this.setA.getBoundingClientRect().left + this.itemLocalPositions = (Array.from(this.setA.children) as HTMLElement[]).map((el) => { + const r = el.getBoundingClientRect() + return r.left + r.width / 2 - setALeft + }) + // While paused the wave loop stays down; resume restarts it and picks up + // the freshly measured positions. + if (this.playState === 'running') this.startWave() } private onResize() { @@ -264,9 +275,10 @@ export class MotionTicker extends Controllable(HTMLElement) { /** * Rebuilds the animation against the current attribute values and geometry, - * carrying over loop progress, rate and the pause state. `speed` is floored - * at MIN_RATE like `resumeCtrls()`, since a running animation at speed 0 - * reads back `time` as 0 and would lose the position on the next resume. + * carrying over the rendered position, rate and the pause state. `speed` is + * floored at MIN_RATE like `resumeCtrls()`, since a running animation at + * speed 0 reads back `time` as 0 and would lose the position on the next + * resume. */ private applyGap() { if (!this.setA) return @@ -281,11 +293,13 @@ export class MotionTicker extends Controllable(HTMLElement) { if (this.playState !== 'running' && this.playState !== 'paused') return if (!this.ctrls || !this.setA || !this.track) return this.applyGap() + this.fillSet(this.originalItems) const w = this.setA.offsetWidth + this.gap if (!w) return - // Progress is a fraction of the *outgoing* animation's own duration, so - // the track keeps its visual position when speed changes the duration. - const progress = (this.ctrls.time / this.ctrls.duration) % 1 + // The new time is derived from the rendered offset rather than the old + // animation's clock, so the track holds its place even when the duration + // or direction it would be measured against has just changed. + const progress = this.renderedProgress(w) this.ctrls.stop() const duration = w / this.speed this.ctrls = animate( @@ -302,6 +316,15 @@ export class MotionTicker extends Controllable(HTMLElement) { // no-op here — the freshly built animation has to be held directly. if (this.playState === 'paused') this.ctrls.pause() this.ctrls.time = progress * duration + this.refreshWave() + } + + private renderedProgress(w: number): number { + const transform = getComputedStyle(this.track!).transform + if (transform === 'none') return 0 + const x = new DOMMatrix(transform).m41 + const p = this.direction === 'left' ? -x / w : x / w + 1 + return ((p % 1) + 1) % 1 } private resumeCtrls() { @@ -346,6 +369,9 @@ export class MotionTicker extends Controllable(HTMLElement) { this.lerpRate(0) } private onLeave = () => { + // A keyboard pause is an explicit request; the pointer or focus wandering + // off must not override it. Space lifts it again. + if (this.keyboardPaused) return if (this.playState === 'paused') void this.play() this.lerpRate(1) }