Promote to main: Feed browser luxury redesign #7

Merged
calcazar merged 49 commits from develop into main 2026-10-20 10:33:01 -06:00
11 changed files with 425 additions and 49 deletions
Showing only changes of commit ce1a3e2eaa - Show all commits
+8 -2
View File
@@ -95,7 +95,8 @@
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off /* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
the light sweep on buttons, and stops snap and smooth scrolling. State the light sweep on buttons, the featured glow, caption fade and slide (its
autoplay is stopped in FeaturedCarousel via matchMedia), and stops snap and smooth scrolling. State
changes still happen instantly. The loading spinner is kept, only slowed, changes still happen instantly. The loading spinner is kept, only slowed,
because it is the only sign that something is loading. */ because it is the only sign that something is loading. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -111,7 +112,12 @@
.btn-gloss::before, .btn-gloss::before,
.btn-gloss::after, .btn-gloss::after,
.btn-sweep::before, .btn-sweep::before,
.account-dot { animation: none !important; } .account-dot,
.featured-cf::before,
.featured-cap { animation: none !important; }
.featured-item,
.featured-ring,
.featured-dot { transition: none !important; }
.btn-sweep::before, .btn-sweep::before,
.btn-gloss::after { transition: none !important; } .btn-gloss::after { transition: none !important; }
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; } .panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
+1
View File
@@ -10,6 +10,7 @@ import "../styles/avatar.css";
import "../styles/signin.css"; import "../styles/signin.css";
import "../styles/post.css"; import "../styles/post.css";
import "../styles/composer.css"; import "../styles/composer.css";
import "../styles/featured.css";
import "../styles/feed.css"; import "../styles/feed.css";
import "../styles/legacy.css"; import "../styles/legacy.css";
+2 -2
View File
@@ -3,14 +3,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { DiscoverData } from "@/lib/discover"; import type { DiscoverData } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client"; import { fetchDiscover } from "@/lib/discover-client";
import { FeaturedExpertsRail } from "./FeaturedExpertsRail"; import { FeaturedCarousel } from "./FeaturedCarousel";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail"; import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) { export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
if (!data) return null; if (!data) return null;
return ( return (
<> <>
<FeaturedExpertsRail experts={data.featuredExperts} /> <FeaturedCarousel experts={data.featuredExperts} />
<TrendingMinnectsRail minnects={data.trendingMinnects} /> <TrendingMinnectsRail minnects={data.trendingMinnects} />
</> </>
); );
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
import { toneFor } from "./HexAvatar";
import { featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia(REDUCED_MOTION);
setReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
function Star() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
</svg>
);
}
function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
const name = expert.displayName || "Expert";
if (expert.pictureUrl) {
// eslint-disable-next-line @next/next/no-img-element
return <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
}
return (
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
<span aria-hidden="true">{initials(name)}</span>
</span>
);
}
export interface FeaturedCarouselProps {
experts: DiscoverExpert[];
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
size?: number;
}
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
const count = experts.length;
const [index, setIndex] = useState(0);
const reduced = useReducedMotion();
const autoplay = useRef<FeaturedAutoplay | null>(null);
const hovered = useRef(false);
useEffect(() => {
if (count < 2 || reduced) return;
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
if (hovered.current) ctl.pause();
autoplay.current = ctl;
return () => { ctl.stop(); autoplay.current = null; };
// The index only seeds a restart; ticking must not recreate the timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, reduced]);
if (count === 0) return null;
const current = index % count;
const cap = experts[current];
const style = { "--cfw": `${size}px` } as CSSProperties;
return (
<section
className="tile panel rail rail-featured featured"
aria-labelledby="featured-title"
onMouseEnter={() => { hovered.current = true; autoplay.current?.pause(); }}
onMouseLeave={() => { hovered.current = false; autoplay.current?.resume(); }}
>
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
<Star />Featured experts
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
<div className="featured-cf" style={style} aria-live="polite" data-index={current}>
{experts.map((e, i) => {
const slot = featuredSlot(i, current, count);
return (
<div
key={String(e.id)}
className={`featured-item featured-${slot}`}
aria-hidden={slot === "c0" ? undefined : true}
data-testid="featured-item"
>
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
</div>
);
})}
</div>
<div className="featured-cap" key={current}>
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
{cap.rating !== null || cap.reviewsCount !== null ? (
<span className="featured-rate">
{cap.rating !== null ? (
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
) : null}
{cap.reviewsCount !== null ? (
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
) : null}
</span>
) : null}
</div>
{count > 1 ? (
<div className="featured-dots" aria-hidden="true">
{experts.map((e, i) => (
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
))}
</div>
) : <div className="featured-dots-gap" />}
</section>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverExpert } from "@/lib/discover";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
return (
<span className="rail-meta">
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
</span>
);
}
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
if (experts.length === 0) return null;
return (
<section className="tile panel rail rail-featured" aria-labelledby="rail-experts">
<div className="panel-head">
<h2 id="rail-experts" className="type-title type-title-strong">Featured experts</h2>
</div>
<ul className="rail-list panel-scroll fade-edges">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
<Rating rating={e.rating} reviews={e.reviewsCount} />
</div>
</li>
))}
</ul>
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
/** Rotation timing from the spec ("Featured experts carousel"). */
export const FEATURED_INTERVAL_MS = 2800;
export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
/**
* Where item `i` sits when `current` is in the center: by its signed offset
* from the current index, wrapped so the shorter way round wins. With two
* items the other one is a single right neighbor, never an empty pair.
*/
export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
let off = (((i - current) % count) + count) % count;
if (off > count / 2) off -= count;
if (off === 0) return "c0";
if (off === 1) return "r1";
if (off === -1) return "l1";
if (off === 2) return "r2";
if (off === -2) return "l2";
return off > 0 ? "r3" : "l3";
}
export interface FeaturedAutoplay {
readonly index: number;
readonly paused: boolean;
pause(): void;
resume(): void;
stop(): void;
}
/**
* Advances the index every `intervalMs` while not paused. A single item never
* rotates. The caller stops it on unmount.
*/
export function startFeaturedAutoplay(opts: {
count: number;
onChange: (index: number) => void;
intervalMs?: number;
start?: number;
}): FeaturedAutoplay {
const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
let index = opts.start ?? 0;
let paused = false;
const timer =
count > 1
? setInterval(() => {
if (paused) return;
index = (index + 1) % count;
onChange(index);
}, intervalMs)
: null;
return {
get index() { return index; },
get paused() { return paused; },
pause() { paused = true; },
resume() { paused = false; },
stop() { if (timer) clearInterval(timer); },
};
}
+14 -2
View File
@@ -127,12 +127,19 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
}, },
]; ];
const expert = (id: string, displayName: string, byline: string, rating: number, reviewsCount: number): DiscoverExpert => ({ const expert = (
id: string,
displayName: string,
byline: string,
rating: number,
reviewsCount: number,
pictureUrl: string | null = null,
): DiscoverExpert => ({
id, id,
slug: displayName.replace(/\s+/g, ""), slug: displayName.replace(/\s+/g, ""),
displayName, displayName,
byline, byline,
pictureUrl: null, pictureUrl,
rating, rating,
reviewsCount, reviewsCount,
available: true, available: true,
@@ -145,6 +152,11 @@ export const FIXTURE_FEATURED: DiscoverExpert[] = [
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58), expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140), expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33), expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
// Photo placeholders exercise the image path of the featured carousel; the
// rest show initials.
expert("e9", "Leila Haddad", "Marketplace operations", 4.9, 181, placeholderImage(10)),
expert("e10", "Tomas Ferreira", "Hiring and team design", 4.7, 64, placeholderImage(190)),
expert("e11", "Grace Whitfield", "Brand and storytelling", 4.8, 102, placeholderImage(300)),
]; ];
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({ const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
+116
View File
@@ -0,0 +1,116 @@
/* Featured experts carousel (ADR 0001, section 6; spec "Featured experts
carousel"). Ported from the mockup's .cf rules; colors are tokens only.
--cfw is the center photo width, set by the component's size prop. */
.featured { flex: none; }
.featured-title { display: inline-flex; align-items: center; gap: 8px; }
.featured-title svg { width: 16px; height: 16px; color: var(--accent); }
.featured-count { font-variant-numeric: tabular-nums; }
.featured-cf {
--cfw: 96px;
position: relative;
height: calc(var(--cfw) * 1.155 + 6px);
overflow: hidden;
overflow: clip;
overflow-clip-margin: 24px;
}
.featured-cf::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--cfw) * 1.9);
height: calc(var(--cfw) * 1.3);
transform: translate(-50%, -50%);
background: radial-gradient(closest-side, color-mix(in oklab, var(--accent), transparent 70%), transparent);
filter: blur(12px);
animation: featured-breathe 4s ease-in-out infinite;
}
@keyframes featured-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
.featured-item {
position: absolute;
left: 50%;
top: 0;
width: var(--cfw);
margin-left: calc(var(--cfw) / -2);
transition: transform 1.1s var(--ease), opacity 1.1s, filter 1.1s;
}
.featured-ring {
display: block;
padding: 3px;
background: var(--line-2);
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
transition: background .8s;
}
.featured-photo {
display: block;
width: 100%;
aspect-ratio: .866;
object-fit: cover;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
/* Photos sit on the tile while loading; initials keep their HexAvatar tone. */
img.featured-photo { background: var(--tile-3); }
.featured-initials {
display: grid;
place-items: center;
color: var(--avatar-ink);
font-weight: 600;
letter-spacing: .02em;
font-size: calc(var(--cfw) * .3);
}
.featured-c0 { z-index: 5; }
.featured-c0 .featured-ring { background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo)); }
.featured-r1 { transform: translateX(76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
.featured-l1 { transform: translateX(-76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
.featured-r2 { transform: translateX(132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
.featured-l2 { transform: translateX(-132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
.featured-r3 { transform: translateX(170%) scale(.3); opacity: 0; z-index: 1; }
.featured-l3 { transform: translateX(-170%) scale(.3); opacity: 0; z-index: 1; }
.featured-cap {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 3px;
padding: 12px 16px 2px;
animation: featured-cap .8s var(--ease) both;
}
@keyframes featured-cap { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.featured-name { font-size: 23px; line-height: 1.1; }
.featured-by {
font-size: 12.5px;
color: var(--ink-3);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.featured-rate {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.featured-rate > span { display: inline-flex; align-items: center; gap: 4px; }
.featured-rate svg { width: 13px; height: 13px; color: var(--accent); }
.featured-rate em { font-style: normal; font-weight: 500; color: var(--ink-3); }
.featured-dots { display: flex; justify-content: center; gap: 5px; padding: 10px 0 16px; }
.featured-dots-gap { height: 16px; }
.featured-dot {
display: block;
width: 5px;
height: 5px;
border-radius: 3px;
background: var(--line-2);
transition: width .5s var(--ease), background-color .5s;
}
.featured-dot-on { width: 18px; background: var(--accent); }
+1 -2
View File
@@ -91,7 +91,7 @@
.bento-left .expert-search { flex: 1 1 0; } .bento-left .expert-search { flex: 1 1 0; }
.bento-center .feed { flex: 1 1 0; } .bento-center .feed { flex: 1 1 0; }
.bento-right { justify-content: flex-start; } .bento-right { justify-content: flex-start; }
.bento-right .rail-featured { flex: 0 1 auto; max-height: 40%; } .bento-right .rail-featured { flex: none; }
.bento-right .rail-trending { flex: 1 1 0; } .bento-right .rail-trending { flex: 1 1 0; }
/* Panel contents keep their inner padding off the scroll edge. */ /* Panel contents keep their inner padding off the scroll edge. */
@@ -107,7 +107,6 @@
@container app (max-width: 1000px) { @container app (max-width: 1000px) {
.bento-col .panel { flex: 0 0 auto; } .bento-col .panel { flex: 0 0 auto; }
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; } .bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
.bento-right .rail-featured { max-height: none; }
} }
/* Account panel ---------------------------------------------------------- */ /* Account panel ---------------------------------------------------------- */
+6 -6
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createElement } from "react"; import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail"; import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail"; import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
import { DiscoverRailsView } from "@/components/DiscoverRails"; import { DiscoverRailsView } from "@/components/DiscoverRails";
import { fetchDiscover } from "@/lib/discover-client"; import { fetchDiscover } from "@/lib/discover-client";
@@ -18,9 +18,9 @@ const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
}); });
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el); const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
describe("FeaturedExpertsRail", () => { describe("FeaturedCarousel (static render)", () => {
it("renders a card with name, byline, rating and reviews", () => { it("renders the center expert with name, byline, rating and reviews", () => {
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] })); const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
expect(out).toContain("<h2"); expect(out).toContain("<h2");
expect(out).toContain("Ann Lee"); expect(out).toContain("Ann Lee");
expect(out).toContain("Growth coach"); expect(out).toContain("Growth coach");
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).toContain("<img"); expect(out).toContain("<img");
}); });
it("falls back to initials and omits missing fields", () => { it("falls back to initials and omits missing fields", () => {
const out = html(createElement(FeaturedExpertsRail, { const out = html(createElement(FeaturedCarousel, {
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })], experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
})); }));
expect(out).not.toContain("<img"); expect(out).not.toContain("<img");
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).not.toContain("review"); expect(out).not.toContain("review");
}); });
it("renders nothing when empty", () => { it("renders nothing when empty", () => {
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe(""); expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
}); });
}); });
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { FEATURED_INTERVAL_MS, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
import type { DiscoverExpert } from "@/lib/discover";
const expert = (i: number): DiscoverExpert => ({
id: i, slug: `e${i}`, displayName: `Expert ${i}`, byline: `Byline ${i}`, pictureUrl: i % 2 ? `https://x/${i}.jpg` : null,
rating: 4.5, reviewsCount: i, available: true, location: { city: null, country: null },
});
const experts = (n: number) => Array.from({ length: n }, (_, i) => expert(i + 1));
describe("featuredSlot", () => {
it("places items by offset from the current index, wrapping round", () => {
const slots = Array.from({ length: 8 }, (_, i) => featuredSlot(i, 0, 8));
expect(slots).toEqual(["c0", "r1", "r2", "r3", "r3", "l3", "l2", "l1"]);
expect(featuredSlot(0, 7, 8)).toBe("r1");
expect(featuredSlot(6, 7, 8)).toBe("l1");
});
it("degrades with one or two experts: no empty neighbors", () => {
expect(featuredSlot(0, 0, 1)).toBe("c0");
expect([featuredSlot(0, 0, 2), featuredSlot(1, 0, 2)]).toEqual(["c0", "r1"]);
expect([featuredSlot(0, 1, 2), featuredSlot(1, 1, 2)]).toEqual(["r1", "c0"]);
expect([0, 1, 2].map((i) => featuredSlot(i, 0, 3))).toEqual(["c0", "r1", "l1"]);
});
});
describe("startFeaturedAutoplay", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("advances every 2.8s and wraps to the start", () => {
const seen: number[] = [];
const ctl = startFeaturedAutoplay({ count: 3, onChange: (i) => seen.push(i) });
expect(FEATURED_INTERVAL_MS).toBe(2800);
vi.advanceTimersByTime(2799);
expect(seen).toEqual([]);
vi.advanceTimersByTime(1);
expect(seen).toEqual([1]);
vi.advanceTimersByTime(2800 * 2);
expect(seen).toEqual([1, 2, 0]);
expect(ctl.index).toBe(0);
ctl.stop();
});
it("does not advance while paused and resumes after", () => {
const onChange = vi.fn();
const ctl = startFeaturedAutoplay({ count: 5, onChange });
ctl.pause();
expect(ctl.paused).toBe(true);
vi.advanceTimersByTime(4000 * 3);
expect(onChange).not.toHaveBeenCalled();
expect(ctl.index).toBe(0);
ctl.resume();
vi.advanceTimersByTime(2800);
expect(onChange).toHaveBeenCalledWith(1);
ctl.stop();
});
it("never rotates a single expert and stops cleanly", () => {
const one = vi.fn();
startFeaturedAutoplay({ count: 1, onChange: one });
vi.advanceTimersByTime(10_000);
expect(one).not.toHaveBeenCalled();
const many = vi.fn();
const ctl = startFeaturedAutoplay({ count: 4, onChange: many });
ctl.stop();
vi.advanceTimersByTime(10_000);
expect(many).not.toHaveBeenCalled();
});
});
describe("FeaturedCarousel markup", () => {
it("has a polite live region, hides non-center items and shows the counter and dots", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
expect(out).toContain('aria-live="polite"');
expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
expect(out).toContain("featured-c0");
expect(out).toContain(">1 / 8<");
expect(out.match(/class="featured-dot( featured-dot-on)?"/g)).toHaveLength(8);
expect(out.match(/featured-dot-on/g)).toHaveLength(1);
expect(out).toContain("--cfw:96px");
});
it("takes a size prop for mobile", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(3), size: 156 }));
expect(out).toContain("--cfw:156px");
});
it("shows no dots for a single expert", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
expect(out).not.toContain("featured-dot ");
expect(out).toContain(">1 / 1<");
});
});