Files
ComfyUI_frontend/apps/website/e2e/responsive.spec.ts
Yourz 6d5fa743b3 fix: seamless SocialProofBar marquee loop (#12139)
*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 <glary-bot@users.noreply.github.com>
2026-05-11 18:36:40 +00:00

153 lines
5.0 KiB
TypeScript

import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop layout @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('navigation links visible and hamburger hidden', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await expect(desktopLinks.getByText('PRODUCTS').first()).toBeVisible()
await expect(desktopLinks.getByText('PRICING').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Toggle menu' })).toBeHidden()
})
test('product cards in grid layout', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
expect(firstBox, 'first card bounding box').not.toBeNull()
expect(secondBox, 'second card bounding box').not.toBeNull()
expect(firstBox!.y).toBeCloseTo(secondBox!.y, 0)
})
})
test.describe('Mobile layout @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger visible and desktop nav hidden', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('SocialProofBar shows two marquee rows on mobile', async ({ page }) => {
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<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(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)
}