rnxcmkidd: Add the viewport-fit bento shell and a dev feed preview with fixtures

This commit is contained in:
2026-10-19 22:08:21 -06:00
parent 9aeb9a379f
commit 89064e88c1
15 changed files with 638 additions and 120 deletions
+11
View File
@@ -0,0 +1,11 @@
// Dev-only preview of the signed-in feed shell with fixture data (no Minnect
// login, no network). Not linked from the app and returns 404 in production.
import { notFound } from "next/navigation";
import { FeedPreview } from "@/dev/FeedPreview";
export const metadata = { title: "Feed (dev)" };
export default function FeedDevPage() {
if (process.env.NODE_ENV === "production") notFound();
return <FeedPreview />;
}
+3
View File
@@ -19,6 +19,8 @@
--danger: #FF6B70;
--glass: rgba(18,18,21,.72);
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
/* Panels (bento tiles): a faint top highlight and a soft drop. */
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 0 0 1px rgba(255,255,255,.02), 0 24px 44px -24px rgba(0,0,0,.8);
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
@@ -42,6 +44,7 @@
--danger: #B3121A;
--glass: rgba(251,250,248,.74);
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 1px 2px rgba(26,28,31,.04), 0 16px 36px -20px rgba(26,28,31,.22);
--shadow-btn: 0 10px 20px -6px rgba(26,28,31,.22);
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
--neu-d: rgba(26,28,31,.17);
+1
View File
@@ -4,6 +4,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
import "./globals.css";
import "../styles/type.css";
import "../styles/layout.css";
import "../styles/buttons.css";
import "../styles/avatar.css";
import "../styles/signin.css";
+7 -25
View File
@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { loadSession, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo";
import { AppShell } from "./AppShell";
import { Feed } from "./Feed";
import { DiscoverRails } from "./DiscoverRails";
import { ExpertSearch } from "./ExpertSearch";
@@ -41,28 +40,11 @@ export function App() {
}} />;
}
return (
<main className="shell">
<header>
<Logo size={32} />
<div>
<strong>{state.person.name}</strong>
<span>{state.person.email}</span>
</div>
</header>
<div className="columns">
<nav className="col-left" aria-label="Main">
<ul>
<li><Link href="/" aria-current="page">Feed</Link></li>
</ul>
<ExpertSearch />
</nav>
<div className="col-center">
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</div>
<aside className="col-right" aria-label="Discover">
<DiscoverRails />
</aside>
</div>
</main>
<AppShell
person={state.person}
search={<ExpertSearch />}
feed={<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />}
discover={<DiscoverRails />}
/>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import type { ReactNode } from "react";
import type { Person } from "@/lib/auth-client";
import { useTheme } from "@/lib/use-theme";
import { HexAvatar } from "./HexAvatar";
import { Logo } from "./Logo";
const iconProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
function SunIcon() {
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
function MoonIcon() {
return (
<svg {...iconProps}>
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
</svg>
);
}
/** Left account panel: logo, wordmark, theme toggle, the person, live status. */
export function AccountPanel({ person }: { person: Person }) {
const { theme, toggleTheme } = useTheme();
const next = theme === "dark" ? "light" : "dark";
return (
<section className="tile account" aria-label="Account">
<div className="account-brand">
<Logo size={32} />
<span className="account-word type-wordmark">MINNECT</span>
<button type="button" className="account-theme" aria-label={`Switch to ${next} theme`} title={`Switch to ${next} theme`} onClick={toggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
</div>
<div className="account-me">
<HexAvatar name={person.name || person.email} pictureUrl={person.pictureUrl} size="lg" />
<div className="account-me-text">
<strong>{person.name}</strong>
<span>{person.email}</span>
</div>
</div>
<div className="account-live">
<span className="account-dot" aria-hidden="true" />
Feed updates live
</div>
</section>
);
}
export interface AppShellProps {
person: Person;
/** Find experts panel (left column, under the account panel). */
search: ReactNode;
/** Feed panel (center column). */
feed: ReactNode;
/** Featured experts and Trending minnects panels (right column). */
discover: ReactNode;
}
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
export function AppShell({ person, search, feed, discover }: AppShellProps) {
return (
<div className="app">
<main className="bento">
<div className="bento-col bento-left">
<AccountPanel person={person} />
{search}
</div>
<div className="bento-col bento-center">{feed}</div>
<aside className="bento-col bento-right" aria-label="Discover">
{discover}
</aside>
</main>
</div>
);
}
+7 -10
View File
@@ -17,14 +17,15 @@ export interface ExpertSearchViewProps {
onCategory: (name: string | null) => void;
status: SearchStatus;
results: ExpertResult[];
defaultOpen?: boolean;
}
export function ExpertSearchView(p: ExpertSearchViewProps) {
return (
<details className="expert-search" open={p.defaultOpen}>
<summary>Find experts</summary>
<form role="search" onSubmit={(e) => e.preventDefault()}>
<section className="tile panel expert-search" aria-labelledby="h-find">
<div className="panel-head">
<h2 id="h-find" className="type-title type-title-strong">Find experts</h2>
</div>
<form role="search" className="search-form" onSubmit={(e) => e.preventDefault()}>
<label htmlFor="expert-q">Search experts</label>
<input
id="expert-q"
@@ -51,7 +52,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
))}
</div>
) : null}
<div aria-live="polite" className="search-results">
<div aria-live="polite" className="search-results panel-scroll fade-edges">
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
{p.status === "ready" && p.results.length === 0 ? (
@@ -72,7 +73,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
</ul>
) : null}
</div>
</details>
</section>
);
}
@@ -84,11 +85,9 @@ export function ExpertSearch() {
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [status, setStatus] = useState<SearchStatus>("idle");
const [results, setResults] = useState<ExpertResult[]>([]);
const [open, setOpen] = useState(true);
const seq = useRef(0);
useEffect(() => {
setOpen(window.matchMedia("(min-width: 960px)").matches);
let live = true;
fetchDiscover().then(
(d) => { if (live) setCategories(d.categories); },
@@ -117,8 +116,6 @@ export function ExpertSearch() {
return (
<ExpertSearchView
key={open ? "open" : "closed"}
defaultOpen={open}
query={query}
onQuery={setQuery}
categories={categories}
+5 -3
View File
@@ -14,9 +14,11 @@ function Rating({ rating, reviews }: { rating: number | null; reviews: number |
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
if (experts.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-experts">
<h2 id="rail-experts">Featured experts</h2>
<ul className="rail-list">
<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" />
+5 -2
View File
@@ -29,6 +29,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
// re-checks the feed instead of creating a duplicate.
const unconfirmed = useRef<{ id: string | null }>({ id: null });
const sentinel = useRef<HTMLDivElement | null>(null);
const list = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
@@ -93,7 +94,8 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: "400px" },
// The list scrolls inside its panel, so it is the observer root.
{ root: list.current, rootMargin: "400px" },
);
io.observe(el);
return () => io.disconnect();
@@ -224,7 +226,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
window.scrollTo({ top: 0, behavior: "smooth" });
list.current?.scrollTo({ top: 0, behavior: "smooth" });
}
return (
@@ -246,6 +248,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
sentinelRef={sentinel}
listRef={list}
/>
);
}
+57 -42
View File
@@ -1,4 +1,4 @@
import type { Ref } from "react";
import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer";
import { PostCard } from "./PostCard";
@@ -24,6 +24,8 @@ export interface FeedViewProps {
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
/** The scrolling feed list; the page itself never scrolls. */
listRef?: Ref<HTMLDivElement>;
now?: number;
}
@@ -31,63 +33,76 @@ function Spinner() {
return <span className="spinner" role="status" aria-label="Loading" />;
}
function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) {
return (
<section className="tile panel feed" aria-labelledby="h-feed">
<div className="panel-head feed-head">
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
</div>
{composer}
{children}
</section>
);
}
export function FeedView(p: FeedViewProps) {
if (p.status === "loading") {
return (
<section className="feed">
<FeedPanel>
<div className="feed-center">
<Spinner />
</div>
</section>
</FeedPanel>
);
}
if (p.status === "error") {
return (
<section className="feed">
<FeedPanel>
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
</section>
</FeedPanel>
);
}
return (
<section className="feed">
{p.composer ? <Composer {...p.composer} /> : null}
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</section>
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null}>
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</div>
</FeedPanel>
);
}
+5 -3
View File
@@ -5,9 +5,11 @@ import { MinnectMedia } from "./MinnectMedia";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-trending">
<h2 id="rail-trending">Trending minnects</h2>
<ul className="rail-list">
<section className="tile panel rail rail-trending" aria-labelledby="rail-trending">
<div className="panel-head">
<h2 id="rail-trending" className="type-title type-title-strong">Trending minnects</h2>
</div>
<ul className="rail-list panel-scroll fade-edges">
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (
+73
View File
@@ -0,0 +1,73 @@
"use client";
// Dev-only: the real signed-in shell with fixture data and no network calls.
// Likes, search and the composer work on local state so the panels can be
// checked by hand. Used by src/app/dev/feed/page.tsx.
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch";
import { FeedView } from "@/components/FeedView";
import { toCardPosts, type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
// With no query or category, show every fixture result so the list scrolls.
const results = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? FIXTURE_SEARCH_RESULTS.filter((e) => e.displayName.toLowerCase().includes(q)) : FIXTURE_SEARCH_RESULTS;
}, [query]);
const toggleLike = (post: CardPost) =>
setPosts((cur) =>
cur.map((p) =>
p.key === post.key ? { ...p, isLiked: !p.isLiked, likesCount: p.likesCount + (p.isLiked ? -1 : 1) } : p,
),
);
return (
<AppShell
person={FIXTURE_PERSON}
search={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
feed={
<FeedView
posts={posts}
status="ready"
loadingMore={false}
loadMoreFailed={false}
newCount={0}
onRetry={() => {}}
onShowNew={() => {}}
onToggleLike={toggleLike}
feedUserId={FIXTURE_PERSON.feedUserId}
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
composer={{ value: draft, busy: false, error: false, onChange: setDraft, onSubmit: () => setDraft("") }}
now={FIXTURE_NOW}
/>
}
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
/>
);
}
+182
View File
@@ -0,0 +1,182 @@
// Fixture data for the dev-only preview routes (/dev/feed). Nothing here is
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
// so the preview never calls the network. Later tasks extend this file.
import type { Person } from "@/lib/auth-client";
import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ExpertResult } from "@/lib/experts-search";
/** Fixed "now" so relative times in the preview are stable. */
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = ""): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
(label
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
: "") +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export const FIXTURE_FEED_USER_ID = "dev-user-1";
export const FIXTURE_PERSON: Person = {
name: "Jordan Rivera",
email: "jordan.rivera@example.com",
pictureUrl: null,
feedUserId: FIXTURE_FEED_USER_ID,
};
const author = (id: string, displayName: string, picture: string | null = null) => ({
id,
displayName,
profilePictureUrl: picture,
});
/** Raw feed items in the shape /api/feed returns; run them through toCardPosts. */
export const FIXTURE_FEED_ITEMS: unknown[] = [
{
type: "post",
publishedAt: "2026-10-19T11:42:00Z",
likesCount: 4,
commentsCount: 1,
post: {
id: "p1",
content: "Shipped the new onboarding flow this morning. The best feedback so far came from a five minute call, not a survey.",
author: author(FIXTURE_FEED_USER_ID, "Jordan Rivera"),
mediaItems: [],
},
},
{
type: "post",
publishedAt: "2026-10-19T10:15:00Z",
likesCount: 128,
commentsCount: 23,
isLiked: true,
post: {
id: "p2",
content: "Three questions I ask before every pricing change:\n• Who pays more?\n• Who leaves?\n• What do we learn either way?",
author: author("a2", "Priya Natarajan"),
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(352, "Pricing review"), order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T08:30:00Z",
likesCount: 56,
commentsCount: 7,
post: {
id: "p3",
content: "A short walkthrough of how we run weekly reviews with a remote team.",
author: author("a3", "Marcus Bell"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-18T19:05:00Z",
likesCount: 9,
commentsCount: 0,
post: {
id: "p4",
content: "Reading list for anyone moving from engineering into product: start with the customer interviews, then the numbers.",
author: author("a4", "Elena Kovac"),
mediaItems: [],
},
},
{
type: "post",
publishedAt: "2026-10-18T14:40:00Z",
likesCount: 312,
commentsCount: 41,
post: {
id: "p5",
content: "Fundraising tip: send the update even when the month was bad. Especially when the month was bad.",
author: author("a5", "Samuel Okafor"),
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(30, "Monthly update"), order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-17T09:00:00Z",
likesCount: 17,
commentsCount: 3,
post: {
id: "p6",
content: "Your first ten customers will tell you what the product is. Listen before you build the next feature.",
author: author("a6", "Hannah Lindqvist"),
mediaItems: [],
},
},
];
const expert = (id: string, displayName: string, byline: string, rating: number, reviewsCount: number): DiscoverExpert => ({
id,
slug: displayName.replace(/\s+/g, ""),
displayName,
byline,
pictureUrl: null,
rating,
reviewsCount,
available: true,
location: { city: null, country: null },
});
export const FIXTURE_FEATURED: DiscoverExpert[] = [
expert("e1", "Priya Natarajan", "Pricing and growth advisor", 4.9, 212),
expert("e2", "Marcus Bell", "Remote team coach", 4.8, 96),
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),
];
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
id,
question,
views,
thumbnailUrl: placeholderImage(hue),
media: null,
expert: { slug: who.slug, displayName: who.displayName, pictureUrl: null, byline: who.byline, rating: who.rating },
});
export const FIXTURE_TRENDING: DiscoverMinnect[] = [
minnect("m1", FIXTURE_FEATURED[0], "How do I raise prices without losing my best customers?", 12840, 352),
minnect("m2", FIXTURE_FEATURED[3], "What should a pre-seed deck show in the first three slides?", 9310, 30),
minnect("m3", FIXTURE_FEATURED[1], "How often should a remote team meet in person?", 6120, 210),
minnect("m4", FIXTURE_FEATURED[2], "When is the right time to hire a first product manager?", 4470, 270),
minnect("m5", FIXTURE_FEATURED[4], "How many customer interviews are enough before building?", 2015, 150),
];
export const FIXTURE_CATEGORIES: DiscoverCategory[] = [
{ id: 1, name: "Business", icon: null, sortOrder: 1 },
{ id: 2, name: "Coaching", icon: null, sortOrder: 2 },
{ id: 3, name: "Finance", icon: null, sortOrder: 3 },
{ id: 4, name: "Product", icon: null, sortOrder: 4 },
{ id: 5, name: "Media", icon: null, sortOrder: 5 },
];
export const FIXTURE_DISCOVER: DiscoverData = {
featuredExperts: FIXTURE_FEATURED,
trendingMinnects: FIXTURE_TRENDING,
categories: FIXTURE_CATEGORIES,
};
/** Find experts results: every featured expert plus a few more, filterable by name. */
export const FIXTURE_SEARCH_RESULTS: ExpertResult[] = [
...FIXTURE_FEATURED,
expert("e6", "Daniel Moreau", "Sales operations", 4.5, 21),
expert("e7", "Aiko Tanaka", "Brand strategy", 4.8, 77),
expert("e8", "Rafael Duarte", "Engineering management", 4.4, 18),
].map((e) => ({
id: e.id,
slug: e.slug,
displayName: e.displayName,
byline: e.byline,
pictureUrl: e.pictureUrl,
rating: e.rating,
categories: [],
}));
+189
View File
@@ -0,0 +1,189 @@
/* Signed-in shell (spec "Desktop and tablet layout", ADR 0001 sections 3 and 6).
.app is the size container; .bento is its child, so container queries can
style it. The page never scrolls: only the panels' own lists do. */
.app {
container: app / inline-size;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--bg);
color: var(--ink);
transition: background-color .45s, color .45s;
}
/* Below 1001px the tablet and phone tasks own the layout. Until then the
panels stack in one column that scrolls inside the app frame. */
.bento {
height: 100%;
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
.bento-col {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
min-height: 0;
}
/* 1001px to 1180px: three columns, narrower sides. */
@container app (min-width: 1001px) {
.bento {
display: grid;
grid-template-columns: 244px minmax(0, 1fr) 292px;
overflow: hidden;
}
.bento-col { height: 100%; }
}
/* 1181px and wider. */
@container app (min-width: 1181px) {
.bento { grid-template-columns: 272px minmax(0, 1fr) 324px; }
}
/* Panels ----------------------------------------------------------------- */
.tile {
position: relative;
min-height: 0;
background: var(--tile);
border: 1px solid var(--line);
border-radius: var(--r-panel);
box-shadow: var(--shadow-tile);
transition: background-color .45s, border-color .45s;
}
.panel { display: flex; flex-direction: column; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 18px 18px 12px;
}
.panel-head h2 { margin: 0; font-size: 21px; }
/* A list that scrolls inside its panel. */
.panel-scroll {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--line-2) transparent;
scroll-behavior: smooth;
}
/* Faded top and bottom edges on a scrolling list. The mask color is only an
alpha source, it is never painted. */
.fade-edges {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
}
/* Which panels grow to fill their column. */
.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-trending { flex: 1 1 0; }
/* Panel contents keep their inner padding off the scroll edge. */
.expert-search .search-form { margin: 0 14px; display: flex; flex-direction: column; }
.expert-search .search-form input { width: 100%; }
.expert-search .chips { padding: 0 14px; }
.expert-search .search-results { padding: 12px 14px 24px; }
.rail .rail-list { padding: 4px 14px 24px; }
.feed .composer { margin: 0 14px 4px; }
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
/* Single-column fallback (below 1001px): lists get a usable height. */
@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 ---------------------------------------------------------- */
.account { flex: none; }
.account-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 12px 12px 16px;
}
.account-word { flex: 1; font-size: 15px; }
.account-theme {
appearance: none;
flex: none;
display: inline-grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--r-ctl);
background: transparent;
color: var(--ink-2);
cursor: pointer;
transition: background-color .2s, color .2s, transform .15s;
}
.account-theme:hover { background: var(--tile-2); color: var(--ink); }
.account-theme:active { transform: scale(.9); }
.account-theme svg { width: 20px; height: 20px; }
.account-me {
display: flex;
align-items: center;
gap: 12px;
margin: 0 12px;
padding: 12px;
border-radius: 14px;
background: var(--tile-2);
}
.account-me-text { display: flex; flex-direction: column; min-width: 0; }
.account-me-text strong { font-size: 14px; }
.account-me-text span {
font-size: 12.5px;
color: var(--ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-live {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 18px 16px;
font-size: 12.5px;
color: var(--ink-3);
}
.account-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: account-ping 2.2s infinite;
}
@keyframes account-ping {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 45%); }
70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent), transparent 100%); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 100%); }
}
@media (prefers-reduced-motion: reduce) {
.account-dot { animation: none !important; }
.panel-scroll { scroll-behavior: auto !important; }
}
+2 -34
View File
@@ -9,12 +9,6 @@ input { font: inherit; padding: 12px; border: 1px solid var(--line-2); border-ra
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--accent); color: white; cursor: pointer; }
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--tile); border-bottom: 1px solid var(--line); }
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
.shell header span { color: var(--ink-3); }
.shell header { position: sticky; top: 0; z-index: 5; }
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: transparent; color: var(--ink); border: 1px solid var(--ink); }
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.post { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.post-head { display: flex; align-items: center; gap: 12px; }
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
@@ -31,7 +25,7 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
.feed-center { display: grid; place-items: center; padding: 16px; }
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
.sentinel { height: 1px; }
.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--ink-3); border-radius: 8px; font-size: 14px; font-weight: 400; }
.like-btn:hover:not(:disabled) { color: var(--accent); background: var(--accent-soft); }
@@ -77,16 +71,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
.menu .menu-danger { color: var(--danger); }
.menu-ask { font-size: 13px; color: var(--ink-3); padding: 4px 8px; }
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
.col-left > ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
.col-left a[aria-current="page"] { background: var(--ink); color: var(--bg); }
.col-center { flex: 1 1 400px; min-width: 0; }
.col-center .feed { padding-left: 0; padding-right: 0; }
.col-right { flex: 0 0 300px; position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 20px; }
.rail h2 { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.rail-card { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
.expert-card { align-items: center; }
@@ -100,25 +84,9 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.minnect-video { background: black; border-radius: 0; }
.minnect-audio { width: 100%; height: 36px; }
@media (max-width: 959px) {
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
.col-left > ul { flex-direction: row; }
.col-center { flex: 1 1 100%; order: 3; }
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
.col-right .rail-card { flex: 0 0 260px; }
}
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
.expert-search { margin-top: 16px; }
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
.expert-search form { display: flex; flex-direction: column; }
.expert-search input { border-radius: 0; width: 100%; }
/* Category chips (Find experts panel). Squared corners, monochrome + red. */
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: transparent; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: white; }
.search-results { margin-top: 12px; }
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
@media (max-width: 959px) {
.col-left .expert-search { flex-basis: 100%; }
}
+1 -1
View File
@@ -91,7 +91,7 @@ describe("fetchExperts", () => {
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
renderToStaticMarkup(createElement(ExpertSearchView, {
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o,
activeCategory: null, onCategory: () => {}, status: "idle", results: [], ...o,
}));
describe("ExpertSearchView", () => {