-
Notifications
You must be signed in to change notification settings - Fork 8
web: port ClusterMAX quote carousel — logo strip + inline quote icon #394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import Link from 'next/link'; | ||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import { Quote } from 'lucide-react'; | ||
|
|
||
| import { track } from '@/lib/analytics'; | ||
| import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; | ||
|
|
@@ -66,36 +67,42 @@ function buildCompanyQuotes(quotes: CarouselQuote[], order?: string[]): CompanyE | |
| return shuffleArray(entries); | ||
| } | ||
|
|
||
| function QuoteBlock({ quote }: { quote: CarouselQuote }) { | ||
| function QuoteText({ quote }: { quote: CarouselQuote }) { | ||
| return ( | ||
| <blockquote className="w-full"> | ||
| <p className="text-sm lg:text-base leading-relaxed text-muted-foreground italic"> | ||
| “{highlightBrand(quote.text)}” | ||
| <blockquote className="m-0 p-0 border-0"> | ||
| <p className="text-sm lg:text-base leading-relaxed text-muted-foreground"> | ||
| <Quote className="inline-block mr-2 -mt-1 size-4 text-brand align-middle" aria-hidden="true" /> | ||
| {highlightBrand(quote.text)} | ||
| </p> | ||
| <footer className="mt-3 flex items-center gap-3"> | ||
| <CompanyLogo org={quote.org} logo={quote.logo} /> | ||
| <div className="h-12 w-0.5 bg-brand" /> | ||
| <div className="text-sm"> | ||
| {quote.link ? ( | ||
| <a | ||
| href={quote.link} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="font-semibold text-foreground hover:text-brand transition-colors group" | ||
| > | ||
| <span className="group-hover:underline">{quote.name}</span> | ||
| <ExternalLinkIcon /> | ||
| </a> | ||
| ) : ( | ||
| <span className="font-semibold text-foreground">{quote.name}</span> | ||
| )} | ||
| <span className="block text-muted-foreground text-xs">{quote.title}</span> | ||
| </div> | ||
| </footer> | ||
| </blockquote> | ||
| ); | ||
| } | ||
|
|
||
| function QuoteAuthor({ quote }: { quote: CarouselQuote }) { | ||
| return ( | ||
| <div className="flex items-center gap-3"> | ||
| <CompanyLogo org={quote.org} logo={quote.logo} /> | ||
| <div className="h-12 w-0.5 bg-brand" /> | ||
| <div className="text-sm"> | ||
| {quote.link ? ( | ||
| <a | ||
| href={quote.link} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="font-semibold text-foreground hover:text-brand transition-colors group" | ||
| > | ||
| <span className="group-hover:underline">{quote.name}</span> | ||
| <ExternalLinkIcon /> | ||
| </a> | ||
| ) : ( | ||
| <span className="font-semibold text-foreground">{quote.name}</span> | ||
| )} | ||
| <span className="block text-muted-foreground text-xs">{quote.title}</span> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function QuoteCarousel({ | ||
| quotes, | ||
| overrides = {}, | ||
|
|
@@ -160,61 +167,122 @@ export function QuoteCarousel({ | |
|
|
||
| return ( | ||
| <div | ||
| className="flex flex-col gap-4" | ||
| className="flex flex-col gap-5" | ||
| onMouseEnter={() => { | ||
| hovering.current = true; | ||
| }} | ||
| onMouseLeave={() => { | ||
| hovering.current = false; | ||
| }} | ||
| > | ||
| {/* Org name strip */} | ||
| <div className="flex flex-wrap justify-center gap-x-6 md:gap-x-8 gap-y-2 mx-4"> | ||
| {entries.map((e, i) => ( | ||
| <button | ||
| key={e.org} | ||
| type="button" | ||
| onClick={() => goTo(i)} | ||
| className={`text-xs font-semibold tracking-wide uppercase transition-colors duration-200 ${ | ||
| i === activeIndex ? 'text-foreground' : 'text-[#808488] hover:text-muted-foreground' | ||
| }`} | ||
| > | ||
| {labels[e.org] ?? e.org} | ||
| </button> | ||
| ))} | ||
| {/* Org logo strip — infinite marquee carousel; clickable, active is highlighted. | ||
| Each set carries `pr-5` so the trailing gap is baked into the 50% | ||
| translate, keeping the loop seamless. */} | ||
| <div className="overflow-hidden"> | ||
| <div className="flex w-max animate-marquee"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Marquee and quote pause divergeMedium Severity The quote carousel's auto-advance and logo marquee animations pause independently. The quote rotation pauses on hover of the entire carousel, while the logo strip only pauses on hover or focus of the marquee element itself. This leads to scenarios where one component pauses while the other continues, creating an inconsistent user experience. Reviewed by Cursor Bugbot for commit 2042ef8. Configure here. |
||
| {[0, 1].map((copy) => ( | ||
| <div | ||
| key={copy} | ||
| className="flex items-center gap-x-5 pr-5 shrink-0" | ||
| aria-hidden={copy === 1 ? true : undefined} | ||
| > | ||
| {entries.map((e, i) => { | ||
| const isActive = i === activeIndex; | ||
| return ( | ||
| <button | ||
| key={e.org} | ||
| type="button" | ||
| onClick={() => goTo(i)} | ||
| title={labels[e.org] ?? e.org} | ||
| aria-label={`Show quote from ${labels[e.org] ?? e.org}`} | ||
| tabIndex={copy === 1 ? -1 : undefined} | ||
| className={`group flex h-10 shrink-0 items-center justify-center px-2 rounded-md transition-all duration-200 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 ${ | ||
| isActive | ||
| ? 'bg-accent/60' | ||
| : 'opacity-50 hover:opacity-100' | ||
| }`} | ||
| > | ||
| {e.quote.logo ? ( | ||
| <img | ||
| src={`/logos/${e.quote.logo}`} | ||
| alt={labels[e.org] ?? e.org} | ||
| className={`h-6 sm:h-7 max-w-[110px] object-contain transition-all duration-200 ${ | ||
| isActive ? 'grayscale-0 dark:invert' : 'grayscale dark:invert' | ||
| }`} | ||
| loading="lazy" | ||
| /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strip logos lack error fallbackLow Severity The marquee strip renders sponsor logos with a plain Reviewed by Cursor Bugbot for commit 2042ef8. Configure here. |
||
| ) : ( | ||
| <span | ||
| className={`text-xs font-semibold tracking-wide uppercase ${ | ||
| isActive ? 'text-foreground' : 'text-muted-foreground' | ||
| }`} | ||
| > | ||
| {labels[e.org] ?? e.org} | ||
| </span> | ||
| )} | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* All quotes stacked in same grid cell — tallest sets height */} | ||
| <div className="grid items-center"> | ||
| {/* Stacked quote texts — tallest sets the cell height. */} | ||
| <div className="grid items-start"> | ||
| {entries.map((e, i) => { | ||
| const isActive = i === activeIndex; | ||
| return ( | ||
| <div | ||
| key={e.org} | ||
| className={`col-start-1 row-start-1 ${ | ||
| isActive | ||
| ? `transition-opacity duration-300 ease-in-out ${fading ? 'opacity-0' : 'opacity-100'}` | ||
| ? `transition-opacity duration-300 ease-in-out ${ | ||
| fading ? 'opacity-0' : 'opacity-100' | ||
| }` | ||
| : 'opacity-0 invisible pointer-events-none' | ||
| }`} | ||
| aria-hidden={!isActive} | ||
| > | ||
| <QuoteBlock quote={e.quote} /> | ||
| <QuoteText quote={e.quote} /> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
|
|
||
| {moreHref && ( | ||
| <div className="flex justify-end"> | ||
| {/* Bottom row: active quote's author (left) and "See more" link (right), | ||
| aligned to the same bottom baseline via items-end. */} | ||
| <div className="flex items-end justify-between gap-4"> | ||
| <div className="grid items-end flex-1 min-w-0"> | ||
| {entries.map((e, i) => { | ||
| const isActive = i === activeIndex; | ||
| return ( | ||
| <div | ||
| key={e.org} | ||
| className={`col-start-1 row-start-1 ${ | ||
| isActive | ||
| ? `transition-opacity duration-300 ease-in-out ${ | ||
| fading ? 'opacity-0' : 'opacity-100' | ||
| }` | ||
| : 'opacity-0 invisible pointer-events-none' | ||
| }`} | ||
| aria-hidden={!isActive} | ||
| > | ||
| <QuoteAuthor quote={e.quote} /> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| {moreHref && ( | ||
| <Link | ||
| href={moreHref} | ||
| className="text-xs font-bold text-brand hover:underline" | ||
| className="text-xs font-bold text-brand hover:underline shrink-0" | ||
| onClick={() => track('quote_carousel_see_more_clicked')} | ||
| > | ||
| See more supporters → | ||
| </Link> | ||
| </div> | ||
| )} | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reduced motion ignores quote rotation
Medium Severity
The new
prefers-reduced-motionrule disables only the logo marquee animation. The carousel still runs its 8ssetIntervaladvance and 300ms opacity fades, so users who request reduced motion see a static strip but quotes keep changing—contrary to the PR’s stated reduced-motion support for the carousel.Additional Locations (1)
packages/app/src/components/quote-carousel.tsx#L136-L145Reviewed by Cursor Bugbot for commit 2042ef8. Configure here.