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
10 changed files with 307 additions and 10 deletions
Showing only changes of commit cfdca1f270 - Show all commits
+1 -1
View File
@@ -49,7 +49,7 @@ function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: ()
<AppShell
person={person}
search={<ExpertSearch />}
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} />}
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} trendingMinnects={discover?.trendingMinnects} searchPanel={<ExpertSearch />} />}
discover={<DiscoverRailsView data={discover} />}
/>
);
+2 -2
View File
@@ -96,11 +96,11 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
<main className="bento">
<div className="bento-col bento-left">
<AccountPanel person={person} />
{search}
{compact ? null : search}
</div>
<div className="bento-col bento-center">{feed}</div>
<aside className="bento-col bento-right" aria-label="Discover">
{discover}
{compact ? null : discover}
</aside>
</main>
</div>
+5 -2
View File
@@ -147,14 +147,17 @@ export interface DepthButtonProps extends NativeButton {
label: string;
sublabel?: string;
icon?: ReactNode;
/** Raw leading content in place of the icon tile, for example a stack of avatars. */
leading?: ReactNode;
/** Trailing content, for example a chevron or the current theme name. */
trailing?: ReactNode;
}
export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) {
export function DepthButton({ label, sublabel, icon, leading, trailing, className, type = "button", ...rest }: DepthButtonProps) {
return (
<button {...rest} type={type} className={cx("btn-depth", className)}>
{icon ? (
{leading ?? null}
{!leading && icon ? (
<span className="btn-depth-icon" aria-hidden="true">
{icon}
</span>
+148
View File
@@ -0,0 +1,148 @@
"use client";
// Mobile Discover sheet (spec "Sheets"): three soft depth rows that open the
// Featured experts, Trending minnects and Find experts panels as 80% height
// sheets, then the Dark/Light appearance switch. Styles: src/styles/sheet.css.
import type { ReactNode } from "react";
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import { useTheme } from "@/lib/use-theme";
import type { Theme } from "@/lib/theme";
import { BottomSheet } from "./BottomSheet";
import { DepthButton } from "./Buttons";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { HexAvatar } from "./HexAvatar";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export type DiscoverPanel = "featured" | "trending" | "search";
/** Width of the center photo in the featured panel sheet. */
export const PANEL_CAROUSEL_SIZE = 150;
/** "Name1, Name2 and N more" for the Featured experts row; shorter lists read plainly. */
export function featuredSubtitle(experts: Pick<DiscoverExpert, "displayName">[]): string {
const names = experts.map((e) => e.displayName || "Expert");
if (names.length === 0) return "";
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
}
function Chevron() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="btn-depth-chev">
<path d="m9 6 6 6-6 6" />
</svg>
);
}
const icon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" } as const;
function FlameIcon() {
return (
<svg {...icon}>
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
</svg>
);
}
function SearchIcon() {
return (
<svg {...icon}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
function MoonIcon() {
return (
<svg {...icon} aria-hidden="true">
<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>
);
}
function SunIcon() {
return (
<svg {...icon} aria-hidden="true">
<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>
);
}
/** The Dark/Light switch in the soft depth style; the choice persists through useTheme. */
export function AppearanceSwitch() {
const { theme, setTheme } = useTheme();
const options: { value: Theme; label: string; icon: ReactNode }[] = [
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
{ value: "light", label: "Light", icon: <SunIcon /> },
];
return (
<div className="seg-depth" role="group" aria-label="Appearance">
{options.map((o) => (
<button key={o.value} type="button" className={theme === o.value ? "on" : undefined} aria-pressed={theme === o.value} onClick={() => setTheme(o.value)}>
{o.icon}
{o.label}
</button>
))}
</div>
);
}
export interface DiscoverSheetProps {
experts: DiscoverExpert[];
onOpen: (panel: DiscoverPanel) => void;
onClose: () => void;
}
export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) {
return (
<BottomSheet title="Discover" onClose={onClose} className="sheet-discover">
{experts.length > 0 ? (
<DepthButton
label="Featured experts"
sublabel={featuredSubtitle(experts)}
onClick={() => onOpen("featured")}
leading={
<span className="depth-stack" aria-hidden="true">
{experts.slice(0, 3).map((e) => (
<HexAvatar key={String(e.id)} name={e.displayName || "Expert"} pictureUrl={e.pictureUrl} size="sm" />
))}
</span>
}
trailing={<Chevron />}
/>
) : null}
<DepthButton label="Trending minnects" sublabel="Answers people are watching" icon={<FlameIcon />} trailing={<Chevron />} onClick={() => onOpen("trending")} />
<DepthButton label="Find experts" sublabel="Search by name, topic or category" icon={<SearchIcon />} trailing={<Chevron />} onClick={() => onOpen("search")} />
<span className="type-eyebrow sheet-sub">Appearance</span>
<AppearanceSwitch />
</BottomSheet>
);
}
export interface DiscoverPanelSheetProps {
panel: DiscoverPanel;
experts: DiscoverExpert[];
trending: DiscoverMinnect[];
search?: ReactNode;
onClose: () => void;
}
const TITLES: Record<DiscoverPanel, string> = {
featured: "Featured experts",
trending: "Trending minnects",
search: "Find experts",
};
/** One Discover panel as an 80% height sheet with a close button; reuses the desktop components. */
export function DiscoverPanelSheet({ panel, experts, trending, search, onClose }: DiscoverPanelSheetProps) {
return (
<BottomSheet title={TITLES[panel]} onClose={onClose} className={`sheet-panel sheet-panel-${panel}`}>
{panel === "featured" ? <FeaturedCarousel experts={experts} size={PANEL_CAROUSEL_SIZE} variant="slide" /> : null}
{panel === "trending" ? <TrendingMinnectsRail minnects={trending} /> : null}
{panel === "search" ? search : null}
</BottomSheet>
);
}
+9 -1
View File
@@ -6,7 +6,8 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import type { DiscoverExpert } from "@/lib/discover";
import type { ReactNode } from "react";
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
import { useCompactLayout } from "./layout-context";
@@ -18,6 +19,8 @@ export function Feed({
feedUserId,
person,
featuredExperts,
trendingMinnects,
searchPanel,
}: {
onSignedOut: () => void;
feedUserId?: string | null;
@@ -25,6 +28,9 @@ export function Feed({
person?: ComposerProps["person"];
/** Shown as a slide after the second post on phones. */
featuredExperts?: DiscoverExpert[];
/** Phone Discover sheet: trending minnects and the Find experts panel. */
trendingMinnects?: DiscoverMinnect[];
searchPanel?: ReactNode;
}) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
@@ -285,6 +291,8 @@ export function Feed({
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
featuredExperts={featuredExperts}
trendingMinnects={trendingMinnects}
searchPanel={searchPanel}
hasMore={!!nextCursor}
sentinelRef={sentinel}
listRef={list}
+5 -1
View File
@@ -2,7 +2,7 @@
import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
import type { DiscoverExpert } from "@/lib/discover";
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import { Composer, type ComposerProps } from "./Composer";
import { GrowButton, IconButton } from "./Buttons";
import { PostCard } from "./PostCard";
@@ -42,6 +42,10 @@ export interface FeedViewProps {
hasMore?: boolean;
/** Phone layout only: experts for the featured slide after the second post. */
featuredExperts?: DiscoverExpert[];
/** Phone layout only: trending minnects for the Discover sheet. */
trendingMinnects?: DiscoverMinnect[];
/** Phone layout only: the Find experts panel shown in the Discover sheet. */
searchPanel?: ReactNode;
/** Phone layout only: the slide on screen changed (for the dock). */
onCurrentChange?: (slide: CurrentSlide) => void;
/** Phone layout only: the dock's comments button, for the post on screen. */
+13 -3
View File
@@ -11,6 +11,7 @@ import { MobileDock } from "./MobileDock";
import { BottomSheet } from "./BottomSheet";
import { CommentsSection } from "./CommentsSection";
import { Composer } from "./Composer";
import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet";
import type { DiscoverExpert } from "@/lib/discover";
import type { FeedViewProps } from "./FeedView";
@@ -281,8 +282,8 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo
export function MobileFeed(p: FeedViewProps) {
const reel = useRef<HTMLDivElement | null>(null);
const [current, setCurrent] = useState(0);
// The open sheet: the comments of one post, the composer, or none.
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | null>(null);
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
const closeSheet = useCallback(() => setSheet(null), []);
// A successful post closes the composer: publishing ends with no error and an emptied draft.
const wasBusy = useRef(false);
@@ -443,7 +444,10 @@ export function MobileFeed(p: FeedViewProps) {
setSheet({ kind: "composer" });
p.onOpenComposer?.();
}}
onOpenMore={p.onOpenMore}
onOpenMore={() => {
setSheet({ kind: "more" });
p.onOpenMore?.();
}}
/>
) : null}
{sheet?.kind === "comments" ? (
@@ -456,6 +460,12 @@ export function MobileFeed(p: FeedViewProps) {
<Composer {...p.composer} />
</BottomSheet>
) : null}
{sheet?.kind === "more" ? (
<DiscoverSheet experts={experts} onOpen={(panel) => setSheet({ kind: "panel", panel })} onClose={closeSheet} />
) : null}
{sheet?.kind === "panel" ? (
<DiscoverPanelSheet panel={sheet.panel} experts={experts} trending={p.trendingMinnects ?? []} search={p.searchPanel} onClose={closeSheet} />
) : null}
</section>
);
}
+12
View File
@@ -126,6 +126,18 @@ export function FeedPreview() {
loadingMore={loadingMore || paging}
hasMore={hasMore}
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
trendingMinnects={FIXTURE_DISCOVER.trendingMinnects}
searchPanel={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
sentinelRef={sentinel}
listRef={listRef}
loadMoreFailed={false}
+62
View File
@@ -88,6 +88,68 @@
.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; }
.sheet-composer .composer-att.composer-att-indent { margin-left: 0; }
/* Discover sheet (More): soft depth rows, then the Appearance switch. */
.sheet-discover { gap: 14px; }
.sheet-discover .sheet-body { gap: 16px; padding: 4px 4px 4px; overflow-y: auto; }
.sheet-discover .btn-depth { position: relative; overflow: hidden; isolation: isolate; min-height: 72px; }
.sheet-discover .btn-depth-chev { width: 18px; height: 18px; flex: none; color: var(--ink-3); transition: color .3s; }
.sheet-discover .btn-depth:hover .btn-depth-chev,
.sheet-discover .btn-depth:focus-visible .btn-depth-chev { color: var(--on-accent); }
.depth-stack { display: flex; flex: none; }
.depth-stack .hex-avatar { margin-left: -10px; }
.depth-stack .hex-avatar:first-child { margin-left: 0; }
.sheet-sub { margin: 4px 0 -6px 4px; color: var(--ink-3); }
.seg-depth {
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--r-card);
background: var(--bg);
box-shadow: inset 4px 4px 10px var(--neu-d), inset -4px -4px 10px var(--neu-l);
}
.seg-depth button {
appearance: none;
border: 0;
cursor: pointer;
flex: 1;
height: 44px;
border-radius: var(--r-ctl);
background: transparent;
color: var(--ink-3);
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background-color .25s, color .25s, box-shadow .25s;
}
.seg-depth button svg { width: 18px; height: 18px; }
.seg-depth button.on {
background: var(--bg);
color: var(--ink);
box-shadow: 4px 4px 10px var(--neu-d), -4px -4px 10px var(--neu-l);
}
.seg-depth button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* Panel sheets: 80% height, the desktop panels without their tile chrome or repeated heading. */
.sheet-panel { height: 80%; max-height: 80%; }
.sheet-panel .sheet-body { flex: 1; overflow: hidden; }
.sheet-panel .sheet-body > .tile {
flex: 1;
min-height: 0;
margin: 0;
padding: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
}
.sheet-panel .sheet-body > .tile > .panel-head { display: none; }
.sheet-panel .search-results,
.sheet-panel .trend-list { flex: 1 1 0; min-height: 0; overflow-y: auto; }
.sheet-panel .search-field input { font-size: 16px; }
.sheet-panel-featured .sheet-body { overflow-y: auto; align-items: center; justify-content: center; }
@media (prefers-reduced-motion: reduce) {
.sheet, .sheet-bg { animation: none !important; }
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { DiscoverPanelSheet, DiscoverSheet, featuredSubtitle, PANEL_CAROUSEL_SIZE } from "@/components/DiscoverSheet";
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
const experts = FIXTURE_DISCOVER.featuredExperts;
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
describe("featuredSubtitle", () => {
it("names two experts and counts the rest", () => {
const list = [{ displayName: "A" }, { displayName: "B" }, { displayName: "C" }, { displayName: "D" }];
expect(featuredSubtitle(list)).toBe("A, B and 2 more");
expect(featuredSubtitle(list.slice(0, 3))).toBe("A, B and 1 more");
});
it("reads plainly for short lists", () => {
expect(featuredSubtitle([])).toBe("");
expect(featuredSubtitle([{ displayName: "A" }])).toBe("A");
expect(featuredSubtitle([{ displayName: "A" }, { displayName: "B" }])).toBe("A and B");
});
});
describe("Discover sheet", () => {
const out = html(createElement(DiscoverSheet, { experts, onOpen: () => {}, onClose: () => {} }));
it("has three depth rows and the appearance switch", () => {
expect(out).toContain("Discover");
expect((out.match(/btn-depth"/g) ?? []).length).toBe(3);
expect(out).toContain("Featured experts");
expect(out).toContain("Trending minnects");
expect(out).toContain("Find experts");
expect(out).toContain('aria-label="Appearance"');
expect(out).toContain("Dark");
expect(out).toContain("Light");
expect(out).toContain(featuredSubtitle(experts));
});
});
describe("Discover panel sheets", () => {
it("shows the featured carousel at 150px", () => {
expect(PANEL_CAROUSEL_SIZE).toBe(150);
const out = html(createElement(DiscoverPanelSheet, { panel: "featured", experts, trending: [], onClose: () => {} }));
expect(out).toContain("--cfw:150px");
expect(out).toContain('aria-label="Close"');
});
it("is 80% high", () => {
const css = readFileSync("src/styles/sheet.css", "utf8");
expect(css).toMatch(/\.sheet-panel\s*\{[^}]*height:\s*80%/);
});
});