diff --git a/src/app/globals.css b/src/app/globals.css index 41f6236..d4414e9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -95,7 +95,8 @@ /* 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 - 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, because it is the only sign that something is loading. */ @media (prefers-reduced-motion: reduce) { @@ -111,7 +112,12 @@ .btn-gloss::before, .btn-gloss::after, .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-gloss::after { transition: none !important; } .panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c89c674..b9a39c0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import "../styles/avatar.css"; import "../styles/signin.css"; import "../styles/post.css"; import "../styles/composer.css"; +import "../styles/featured.css"; import "../styles/feed.css"; import "../styles/legacy.css"; diff --git a/src/components/DiscoverRails.tsx b/src/components/DiscoverRails.tsx index b33989b..cc931a5 100644 --- a/src/components/DiscoverRails.tsx +++ b/src/components/DiscoverRails.tsx @@ -3,14 +3,14 @@ import { useEffect, useState } from "react"; import type { DiscoverData } from "@/lib/discover"; import { fetchDiscover } from "@/lib/discover-client"; -import { FeaturedExpertsRail } from "./FeaturedExpertsRail"; +import { FeaturedCarousel } from "./FeaturedCarousel"; import { TrendingMinnectsRail } from "./TrendingMinnectsRail"; export function DiscoverRailsView({ data }: { data: DiscoverData | null }) { if (!data) return null; return ( <> - + ); diff --git a/src/components/FeaturedCarousel.tsx b/src/components/FeaturedCarousel.tsx new file mode 100644 index 0000000..771fb2f --- /dev/null +++ b/src/components/FeaturedCarousel.tsx @@ -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 ( + + ); +} + +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 {center; + } + return ( + + + + ); +} + +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(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 ( +
{ hovered.current = true; autoplay.current?.pause(); }} + onMouseLeave={() => { hovered.current = false; autoplay.current?.resume(); }} + > +
+ + {`${current + 1} / ${count}`} +
+
+ {experts.map((e, i) => { + const slot = featuredSlot(i, current, count); + return ( +
+ +
+ ); + })} +
+
+ {cap.displayName || "Expert"} + {cap.byline ? {cap.byline} : null} + {cap.rating !== null || cap.reviewsCount !== null ? ( + + {cap.rating !== null ? ( + {cap.rating.toFixed(1)} + ) : null} + {cap.reviewsCount !== null ? ( + ({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"}) + ) : null} + + ) : null} +
+ {count > 1 ? ( + + ) :
} +
+ ); +} diff --git a/src/components/FeaturedExpertsRail.tsx b/src/components/FeaturedExpertsRail.tsx deleted file mode 100644 index 3df8575..0000000 --- a/src/components/FeaturedExpertsRail.tsx +++ /dev/null @@ -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 ( - - {rating !== null ? ★ {rating.toFixed(1)} : null} - {reviews !== null ? ({reviews} {reviews === 1 ? "review" : "reviews"}) : null} - - ); -} - -export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) { - if (experts.length === 0) return null; - return ( -
-
-

Featured experts

-
-
    - {experts.map((e) => ( -
  • - -
    - {e.displayName || "Expert"} - {e.byline ? {e.byline} : null} - -
    -
  • - ))} -
-
- ); -} diff --git a/src/components/featured-carousel-model.ts b/src/components/featured-carousel-model.ts new file mode 100644 index 0000000..fe088c3 --- /dev/null +++ b/src/components/featured-carousel-model.ts @@ -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); }, + }; +} diff --git a/src/dev/fixtures.ts b/src/dev/fixtures.ts index 2794d42..c61c179 100644 --- a/src/dev/fixtures.ts +++ b/src/dev/fixtures.ts @@ -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, slug: displayName.replace(/\s+/g, ""), displayName, byline, - pictureUrl: null, + pictureUrl, rating, reviewsCount, available: true, @@ -145,6 +152,11 @@ export const FIXTURE_FEATURED: DiscoverExpert[] = [ expert("e3", "Elena Kovac", "Product leadership", 4.7, 58), expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140), 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 => ({ diff --git a/src/styles/featured.css b/src/styles/featured.css new file mode 100644 index 0000000..e7885b0 --- /dev/null +++ b/src/styles/featured.css @@ -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); } diff --git a/src/styles/layout.css b/src/styles/layout.css index 64f07c7..20d4674 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -91,7 +91,7 @@ .bento-left .expert-search { flex: 1 1 0; } .bento-center .feed { flex: 1 1 0; } .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; } /* Panel contents keep their inner padding off the scroll edge. */ @@ -107,7 +107,6 @@ @container app (max-width: 1000px) { .bento-col .panel { flex: 0 0 auto; } .bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; } - .bento-right .rail-featured { max-height: none; } } /* Account panel ---------------------------------------------------------- */ diff --git a/tests/discover-rails.test.ts b/tests/discover-rails.test.ts index 87f87af..d40bc35 100644 --- a/tests/discover-rails.test.ts +++ b/tests/discover-rails.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail"; +import { FeaturedCarousel } from "@/components/FeaturedCarousel"; import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail"; import { DiscoverRailsView } from "@/components/DiscoverRails"; import { fetchDiscover } from "@/lib/discover-client"; @@ -18,9 +18,9 @@ const minnect = (o: Partial = {}): DiscoverMinnect => ({ }); const html = (el: ReturnType) => renderToStaticMarkup(el); -describe("FeaturedExpertsRail", () => { - it("renders a card with name, byline, rating and reviews", () => { - const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] })); +describe("FeaturedCarousel (static render)", () => { + it("renders the center expert with name, byline, rating and reviews", () => { + const out = html(createElement(FeaturedCarousel, { experts: [expert()] })); expect(out).toContain(" { expect(out).toContain(" { - const out = html(createElement(FeaturedExpertsRail, { + const out = html(createElement(FeaturedCarousel, { experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })], })); expect(out).not.toContain(" { expect(out).not.toContain("review"); }); it("renders nothing when empty", () => { - expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe(""); + expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe(""); }); }); diff --git a/tests/featured-carousel.test.ts b/tests/featured-carousel.test.ts new file mode 100644 index 0000000..bb3c192 --- /dev/null +++ b/tests/featured-carousel.test.ts @@ -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<"); + }); +});