mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-12 00:42:03 +00:00
*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     ┆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>
153 lines
5.0 KiB
TypeScript
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)
|
|
}
|