From 6d5fa743b35fdff0409d34b6f41aaf93b6d20153 Mon Sep 17 00:00:00 2001 From: Yourz Date: Tue, 12 May 2026 02:36:40 +0800 Subject: [PATCH] fix: seamless SocialProofBar marquee loop (#12139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary The partner-logo marquee on the homepage `SocialProofBar` glitched on every loop restart — a visible jump where the strip snapped back to the start. ## Root cause The previous implementation rendered all logos as siblings of a single flex container and animated the track from `translateX(0)` to `translateX(-50%)`. Because the `gap` utility inserts spacing between every adjacent pair of items (including the seam between the two duplicated halves), `-50%` of the total width does not equal the distance from one half-start to the next half-start. The mismatch (`gap / 2`) is exactly what the eye sees as a jump. ## Fix - Wrap each duplicated half in its own flex group. - Place the two groups as siblings of an outer `flex w-max gap-X` track with a gap that matches the inner gap. - Animate each group by `translateX(calc(-100% - var(--marquee-gap)))`, where `--marquee-gap` is set inline to the same value as the Tailwind gap class. - Scope the `animation` declaration to `@media (prefers-reduced-motion: no-preference)` so reduced-motion users get a stable, non-animated client list instead of the global "snap to 0.01ms" jump. At `t = end`, the second group sits at `x = 0` — exactly where the first group started — so the next animation cycle is visually indistinguishable from the previous frame. The duplicate carries `aria-hidden="true"` so screen readers don't read the client list twice. ## Verification - `pnpm typecheck`, `pnpm format`, `npx eslint` on changed files: clean. - Geometry verified at runtime on desktop (1440×900) and mobile (390×844): copy widths match, second copy lands at `x = 0` at animation end. - New Playwright regression tests (`apps/website/e2e/responsive.spec.ts`) pause the CSS animation, sample bounding rects at `t=0` and `t≈duration`, and assert the seam invariant — covering desktop forward, mobile forward, and mobile reverse marquees. All 5 SocialProofBar tests pass on both `desktop` and `mobile` projects. - Reduced-motion behavior verified in the browser: `animationName: none`, `transform: none`, tracks at their natural positions. Fixes FE-649 ## Screenshots ![Desktop SocialProofBar marquee — continuous strip](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515303716-158840bf-9182-4928-b095-c38f5284419b.png) ![Desktop marquee at the loop boundary — seamless, no visible jump](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304106-c46ab421-7214-4123-a4d0-e819af2e1b49.png) ![Mobile SocialProofBar — two stacked marquee rows](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304521-b0fe828f-90ba-4cf4-bb63-65be4d28f627.png) ![Reduced-motion fallback — stable static client list](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304824-6f3262ab-127c-46e4-bc04-6e7b8850545b.png) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12139-fix-seamless-SocialProofBar-marquee-loop-35d6d73d36508141b6ccf0167016b8c8) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot --- apps/website/e2e/responsive.spec.ts | 102 ++++++++++++++++++ .../common/SocialProofBarSection.vue | 61 +++++++---- apps/website/src/styles/global.css | 12 ++- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/apps/website/e2e/responsive.spec.ts b/apps/website/e2e/responsive.spec.ts index c77e8e4ec7..6488170457 100644 --- a/apps/website/e2e/responsive.spec.ts +++ b/apps/website/e2e/responsive.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test' import { expect } from '@playwright/test' import { test } from './fixtures/blockExternalMedia' @@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => { const mobileContainer = page.getByTestId('social-proof-mobile') await expect(mobileContainer).toBeVisible() }) + + test.describe('SocialProofBar seamless marquee', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test('mobile forward marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) + + test('mobile reverse marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee-reverse' + ) + expectSeamlessReverseLoop(geometry) + }) + }) }) + +test.describe('Desktop SocialProofBar @smoke', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('desktop marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-desktop"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) +}) + +type MarqueeGeometry = { + copyWidths: number[] + startPositions: number[] + endPositions: number[] +} + +async function measureMarqueeLoopGeometry( + page: Page, + selector: string +): Promise { + await page.locator(selector).first().waitFor() + return page.evaluate((sel) => { + const tracks = Array.from( + document.querySelectorAll(sel) + ).slice(0, 2) + const firstAnimation = tracks[0]?.getAnimations()[0] + if (!firstAnimation) { + throw new Error(`No CSS animation found on ${sel}`) + } + const duration = firstAnimation.effect?.getTiming().duration + if (typeof duration !== 'number' || duration <= 1) { + throw new Error( + `Animation on ${sel} has unusable duration: ${String(duration)}` + ) + } + const setAllTimes = (time: number) => { + for (const track of tracks) { + for (const anim of track.getAnimations()) { + anim.currentTime = time + } + } + void document.body.offsetWidth + } + const readX = () => tracks.map((track) => track.getBoundingClientRect().x) + setAllTimes(0) + const startPositions = readX() + const copyWidths = tracks.map( + (track) => track.getBoundingClientRect().width + ) + setAllTimes(duration - 0.1) + const endPositions = readX() + return { copyWidths, startPositions, endPositions } + }, selector) +} + +function expectTwoMatchingCopies(geometry: MarqueeGeometry) { + const { copyWidths } = geometry + expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2) + expect(copyWidths[0]).toBeGreaterThan(0) + expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0) +} + +function expectSeamlessForwardLoop(geometry: MarqueeGeometry) { + expectTwoMatchingCopies(geometry) + // Copy 2 ends the cycle exactly where copy 1 started, so the restart + // (when copy 1 jumps back to its start position) is visually indistinguishable. + expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0) +} + +function expectSeamlessReverseLoop(geometry: MarqueeGeometry) { + expectTwoMatchingCopies(geometry) + // Reverse marquee: copy 1 ends the cycle where copy 2 started. + expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0) +} diff --git a/apps/website/src/components/common/SocialProofBarSection.vue b/apps/website/src/components/common/SocialProofBarSection.vue index 4cea331a62..e261a18591 100644 --- a/apps/website/src/components/common/SocialProofBarSection.vue +++ b/apps/website/src/components/common/SocialProofBarSection.vue @@ -14,23 +14,28 @@ const logos = [ 'Ubisoft' ] -const desktopLogos = Array.from({ length: 4 }, () => logos).flat() -const row1 = logos.slice(0, 6) -const mobileRow1 = [...row1, ...row1] -const row2 = logos.slice(6) -const mobileRow2 = [...row2, ...row2] +const mobileRow1Logos = logos.slice(0, 6) +const mobileRow2Logos = logos.slice(6)