vzxipsule: Replace the featured rail with an auto-rotating FeaturedCarousel

This commit is contained in:
2026-10-19 22:30:16 -06:00
parent 5e391ae678
commit ce1a3e2eaa
11 changed files with 425 additions and 49 deletions
+8 -2
View File
@@ -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; }
+1
View File
@@ -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";
+2 -2
View File
@@ -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 (
<>
<FeaturedExpertsRail experts={data.featuredExperts} />
<FeaturedCarousel experts={data.featuredExperts} />
<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,
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 => ({
+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-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 ---------------------------------------------------------- */