Feed browser: Mobile immersive feed #4

Merged
calcazar merged 9 commits from feature/feed-browser-mobile-immersive-feed into develop 2026-10-20 08:54:40 -06:00
12 changed files with 815 additions and 61 deletions
Showing only changes of commit bf5e0ed475 - Show all commits
+9
View File
@@ -62,6 +62,15 @@
--accent-glow-0: rgba(255,110,110,0); --accent-glow-0: rgba(255,110,110,0);
/* Text and marks on a crimson face: white in both themes. */ /* Text and marks on a crimson face: white in both themes. */
--on-accent: #FFFFFF; --on-accent: #FFFFFF;
/* Over photos and video (mobile slides): white text, dark fades and dark glass, same in both themes. */
--on-media: #FFFFFF;
--on-media-line: rgba(255,255,255,.18);
--on-media-badge: rgba(255,255,255,.5);
--media-fade: rgba(0,0,0,.8);
--media-fade-top: rgba(0,0,0,.45);
--media-fade-0: rgba(0,0,0,0);
--media-glass: rgba(0,0,0,.28);
--media-shadow: rgba(0,0,0,.35);
/* HexAvatar initials: dark muted gradients, same in both themes. */ /* HexAvatar initials: dark muted gradients, same in both themes. */
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12); --hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
+1
View File
@@ -14,6 +14,7 @@ import "../styles/featured.css";
import "../styles/feed.css"; import "../styles/feed.css";
import "../styles/trending.css"; import "../styles/trending.css";
import "../styles/search.css"; import "../styles/search.css";
import "../styles/mobile.css";
import "../styles/legacy.css"; import "../styles/legacy.css";
const manrope = Manrope({ const manrope = Manrope({
+21 -2
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import type { ReactNode } from "react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Person } from "@/lib/auth-client"; import type { Person } from "@/lib/auth-client";
import { useTheme } from "@/lib/use-theme"; import { useTheme } from "@/lib/use-theme";
import { HexAvatar } from "./HexAvatar"; import { HexAvatar } from "./HexAvatar";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { CompactLayoutContext, isCompactWidth } from "./layout-context";
const iconProps = { const iconProps = {
viewBox: "0 0 24 24", viewBox: "0 0 24 24",
@@ -73,8 +74,25 @@ export interface AppShellProps {
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */ /** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
export function AppShell({ person, search, feed, discover }: AppShellProps) { export function AppShell({ person, search, feed, discover }: AppShellProps) {
const root = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
// The CSS switches layout with a container query on .app; the feed also
// needs to know, because the phone feed is different markup (full-screen slides).
useLayoutEffect(() => {
const el = root.current;
if (!el) return;
const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width));
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return ( return (
<div className="app"> <CompactLayoutContext.Provider value={compact}>
<div className="app" ref={root} data-layout={compact ? "compact" : "wide"}>
<main className="bento"> <main className="bento">
<div className="bento-col bento-left"> <div className="bento-col bento-left">
<AccountPanel person={person} /> <AccountPanel person={person} />
@@ -86,5 +104,6 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
</aside> </aside>
</main> </main>
</div> </div>
</CompactLayoutContext.Provider>
); );
} }
+6 -1
View File
@@ -8,6 +8,7 @@ import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client"; import { DeleteError, sendDelete } from "@/lib/delete-client";
import type { ComposerProps } from "./Composer"; import type { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView"; import { FeedView, type FeedStatus } from "./FeedView";
import { useCompactLayout } from "./layout-context";
const POLL_MS = 45_000; const POLL_MS = 45_000;
@@ -44,6 +45,9 @@ export function Feed({
const list = useRef<HTMLDivElement | null>(null); const list = useRef<HTMLDivElement | null>(null);
const busy = useRef(false); const busy = useRef(false);
const shown = useRef<Set<string>>(new Set()); const shown = useRef<Set<string>>(new Set());
// The list element changes between the panel and the phone reel; the
// infinite-scroll observer re-attaches to the new one.
const compact = useCompactLayout();
useEffect(() => { useEffect(() => {
shown.current = new Set(posts.map((p) => p.key)); shown.current = new Set(posts.map((p) => p.key));
@@ -117,7 +121,7 @@ export function Feed({
); );
io.observe(el); io.observe(el);
return () => io.disconnect(); return () => io.disconnect();
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]); }, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]);
// Real-time: poll the first page while the tab is visible. // Real-time: poll the first page while the tab is visible.
useEffect(() => { useEffect(() => {
@@ -276,6 +280,7 @@ export function Feed({
deleteErrors={deleteErrors} deleteErrors={deleteErrors}
onSignedOut={onSignedOut} onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }} composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
hasMore={!!nextCursor}
sentinelRef={sentinel} sentinelRef={sentinel}
listRef={list} listRef={list}
/> />
+11
View File
@@ -1,8 +1,12 @@
"use client";
import type { ReactNode, Ref } from "react"; import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model"; import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer"; import { Composer, type ComposerProps } from "./Composer";
import { GrowButton, IconButton } from "./Buttons"; import { GrowButton, IconButton } from "./Buttons";
import { PostCard } from "./PostCard"; import { PostCard } from "./PostCard";
import { useCompactLayout } from "./layout-context";
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
export type FeedStatus = "loading" | "ready" | "error"; export type FeedStatus = "loading" | "ready" | "error";
@@ -33,6 +37,10 @@ export interface FeedViewProps {
/** The scrolling feed list; the page itself never scrolls. */ /** The scrolling feed list; the page itself never scrolls. */
listRef?: Ref<HTMLDivElement>; listRef?: Ref<HTMLDivElement>;
now?: number; now?: number;
/** Another page can load; the phone feed ends with a loading slide. */
hasMore?: boolean;
/** Phone layout only: the slide on screen changed (for the dock). */
onCurrentChange?: (slide: CurrentSlide) => void;
} }
function Spinner() { function Spinner() {
@@ -104,6 +112,9 @@ function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { chi
} }
export function FeedView(p: FeedViewProps) { export function FeedView(p: FeedViewProps) {
// At 760px and below the feed is the full-screen snap reel instead of the panel.
const compact = useCompactLayout();
if (compact) return <MobileFeed {...p} />;
if (p.status === "loading") { if (p.status === "loading") {
return ( return (
<FeedPanel onRefresh={p.onRefresh} busy> <FeedPanel onRefresh={p.onRefresh} busy>
+281
View File
@@ -0,0 +1,281 @@
"use client";
import { useEffect, useRef, useState, type Ref } from "react";
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
import { GrowButton } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { PlayIcon, PostMenu } from "./PostCard";
import { VideoPlayer } from "./VideoPlayer";
import type { FeedViewProps } from "./FeedView";
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
export interface CurrentSlide {
index: number;
post: CardPost | null;
}
/** Share of a slide that must be on screen before it counts as the current one. */
const CURRENT_RATIO = 0.6;
/** Smallest finger travel, in px, that counts as a swipe to the next or previous slide. */
const SWIPE_MIN = 40;
/** Wheel steps closer together than this belong to one gesture (trackpad inertia). */
const WHEEL_QUIET_MS = 220;
/** A wheel gesture moves at most one slide in this time. */
const WHEEL_STEP_MS = 520;
/** The slide one step away from `from` in the direction of `delta`, kept inside the reel. */
export function stepTarget(from: number, delta: number, count: number): number {
if (!delta || count <= 0) return Math.min(Math.max(from, 0), Math.max(count - 1, 0));
return Math.min(Math.max(from + Math.sign(delta), 0), count - 1);
}
/** Index of the slide on screen, given which slides are now mostly visible. Keeps the old one when none is. */
export function pickCurrent(previous: number, visible: number[]): number {
if (!visible.length) return previous;
return visible.includes(previous) ? previous : Math.min(...visible);
}
function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) (ref as { current: T | null }).current = value;
}
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
const playable = isPlayableVideo(media);
if (playable) {
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
};
return (
<div className="slide-media slide-video" ref={box}>
<div className="slide-art">
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
</div>
{started ? null : (
<>
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={start}>
<PlayIcon />
</button>
{duration ? <span className="post-dur slide-dur">{duration}</span> : null}
</>
)}
</div>
);
}
if (media.src) {
return (
<div className="slide-media">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="slide-art" src={media.src} alt={media.kind === "video" ? "Video thumbnail" : ""} />
</div>
);
}
return (
<div className="slide-media slide-pending" role="img" aria-label="Video is processing">
<span>Video is processing</span>
</div>
);
}
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
const media = post.media.find((m) => m.kind === "video" || m.src) ?? null;
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}`;
return (
<section className={cls} data-index={index} data-testid="feed-slide" aria-label={`Post by ${post.authorName}`}>
{media ? (
<>
<SlideMedia media={media} author={post.authorName} />
<div className="slide-scrim-top" aria-hidden="true" />
<div className="slide-scrim" aria-hidden="true" />
</>
) : (
<div className="slide-quote-w">
<p className="slide-quote">{post.text}</p>
</div>
)}
{own ? (
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
) : null}
<div className="slide-meta">
{p.deleteErrors?.has(post.key) ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
<div className="slide-who">
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div>
<strong>
{post.authorName}
{own ? <span className="post-you slide-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
</div>
</div>
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
</div>
</section>
);
}
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
return (
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
<div className="slide-load-body" role="status" aria-label="Loading more posts">
<div className="slide-skel">
<div className="slide-skel-head">
<i className="slide-skel-hex" />
<div className="slide-skel-lines">
<i style={{ width: "40%" }} />
<i style={{ width: "24%" }} />
</div>
</div>
<i style={{ width: "92%" }} />
<i style={{ width: "76%" }} />
<i style={{ width: "54%" }} />
</div>
</div>
</section>
);
}
/**
* Phone feed (spec "Mobile layout" > "Full-screen feed"): one post per screen,
* snapping. Uses the same posts, delete and paging as the desktop list: the
* reel is the list ref (the infinite-scroll root) and the loading slide holds
* the sentinel. Reports the slide on screen through onCurrentChange.
*/
export function MobileFeed(p: FeedViewProps) {
const reel = useRef<HTMLDivElement | null>(null);
const [current, setCurrent] = useState(0);
const onCurrentChange = p.onCurrentChange;
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
const slideCount = (p.status === "ready" ? p.posts.length : 0) + (showLoad ? 1 : 0);
// Track the slide on screen. A fresh observer per change picks up new slides.
useEffect(() => {
const root = reel.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const ratios = new Map<number, number>();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio);
const visible = [...ratios].filter(([, r]) => r >= CURRENT_RATIO).map(([i]) => i);
setCurrent((prev) => pickCurrent(prev, visible));
},
{ root, threshold: [0, CURRENT_RATIO, 1] },
);
root.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => io.observe(el));
return () => io.disconnect();
}, [slideCount]);
// One wheel notch or one swipe moves exactly one slide. Native snapping alone
// sends a short or slow gesture back to the slide it started on.
useEffect(() => {
const root = reel.current;
if (!root) return;
const count = () => root.querySelectorAll(":scope > .slide").length;
const at = () => Math.round(root.scrollTop / Math.max(root.clientHeight, 1));
const go = (to: number) => {
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.scrollTo({ top: to * root.clientHeight, behavior: calm ? "auto" : "smooth" });
};
let lastWheel = 0;
let lastStep = 0;
let from = 0;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
e.preventDefault();
const now = performance.now();
const fresh = now - lastWheel > WHEEL_QUIET_MS && now - lastStep > WHEEL_STEP_MS;
lastWheel = now;
if (!fresh || !e.deltaY) return;
lastStep = now;
go(stepTarget(at(), e.deltaY, count()));
};
let startY: number | null = null;
const onTouchStart = (e: TouchEvent) => {
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
from = at();
};
const onTouchEnd = (e: TouchEvent) => {
if (startY === null || e.touches.length) return;
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
startY = null;
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
};
root.addEventListener("wheel", onWheel, { passive: false });
root.addEventListener("touchstart", onTouchStart, { passive: true });
root.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
root.removeEventListener("wheel", onWheel);
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchend", onTouchEnd);
};
}, []);
// Keep the index inside the list when posts are removed.
const index = Math.min(current, Math.max(0, slideCount - 1));
const currentPost = p.status === "ready" ? p.posts[index] ?? null : null;
useEffect(() => {
onCurrentChange?.({ index, post: currentPost });
}, [index, currentPost, onCurrentChange]);
// A video keeps playing only while its slide is on screen.
useEffect(() => {
reel.current?.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => {
if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause());
});
}, [index]);
return (
<section className="reel-feed" aria-label="Home feed">
{p.newCount > 0 && p.status === "ready" ? (
<div className="reel-pill">
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
</div>
) : null}
<div
className="reel"
data-testid="feed-reel"
data-current={index}
ref={(el) => {
reel.current = el;
setRef(p.listRef, el);
}}
>
{p.status === "error" ? (
<section className="slide slide-message is-cur" data-index={0} role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</section>
) : null}
{p.status === "ready" && p.posts.length === 0 && !showLoad ? (
<section className="slide slide-message is-cur" data-index={0}>
<p>No posts yet.</p>
</section>
) : null}
{p.status === "ready"
? p.posts.map((post, i) => <PostSlide key={post.key} post={post} index={i} current={i === index} p={p} />)
: null}
{p.status === "ready" && p.loadMoreFailed ? (
<section className="slide slide-message" data-index={p.posts.length} role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</section>
) : null}
{showLoad ? (
<LoadSlide
index={p.status === "ready" ? p.posts.length : 0}
current={index === (p.status === "ready" ? p.posts.length : 0)}
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
/>
) : null}
</div>
</section>
);
}
+35 -19
View File
@@ -59,7 +59,7 @@ function TrashIcon() {
); );
} }
function PlayIcon() { export function PlayIcon() {
return ( return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" /> <path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
@@ -91,13 +91,21 @@ function PostVideo({ media, author }: { media: CardMedia; author: string }) {
); );
} }
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) { export interface PostMenuProps {
const [open, setOpen] = useState(false); post: CardPost;
onDelete?: (post: CardPost) => void;
deleteBusy?: boolean;
/** Extra class on the wrapper, e.g. the mobile slide's frosted position. */
className?: string;
/** Class for the "..." button; defaults to the card's button. */
buttonClassName?: string;
}
/** The own-post "..." menu with the delete confirm. Shared by the card and the mobile slide. */
export function PostMenu({ post, onDelete, deleteBusy, className, buttonClassName = "post-menu-btn" }: PostMenuProps) {
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [beat, setBeat] = useState(false);
const menuBox = useRef<HTMLDivElement>(null); const menuBox = useRef<HTMLDivElement>(null);
const own = !!(canDelete && onDelete);
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
@@ -113,21 +121,10 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
}, [menu]); }, [menu]);
return ( return (
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}> <div className={className ? `post-menu ${className}` : "post-menu"} ref={menuBox}>
<header className="post-head">
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>
{post.authorName}
{own ? <span className="post-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{own ? (
<div className="post-menu" ref={menuBox}>
<button <button
type="button" type="button"
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"} className={menu ? `${buttonClassName} ${buttonClassName}-open` : buttonClassName}
aria-label="More actions" aria-label="More actions"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menu} aria-expanded={menu}
@@ -169,7 +166,26 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
</div> </div>
) : null} ) : null}
</div> </div>
) : null} );
}
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [beat, setBeat] = useState(false);
const own = !!(canDelete && onDelete);
return (
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
<header className="post-head">
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>
{post.authorName}
{own ? <span className="post-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{own ? <PostMenu post={post} onDelete={onDelete} deleteBusy={deleteBusy} /> : null}
</header> </header>
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null} {deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null} {post.text ? <p className="post-text">{post.text}</p> : null}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { createContext, useContext } from "react";
/** Widest `.app` width that gets the phone layout (spec "Mobile layout", ADR 0001 section 3). */
export const COMPACT_MAX_WIDTH = 760;
/** True when `.app` is 760px wide or less. Matches `@container app (max-width: 760px)`. */
export function isCompactWidth(width: number): boolean {
return width > 0 && width <= COMPACT_MAX_WIDTH;
}
/**
* Whether the signed-in shell is in the phone layout. AppShell measures `.app`
* and provides it; outside the shell (tests, other pages) it is false, so the
* desktop markup renders.
*/
export const CompactLayoutContext = createContext(false);
export function useCompactLayout(): boolean {
return useContext(CompactLayoutContext);
}
+37 -3
View File
@@ -3,7 +3,7 @@
// Dev-only: the real signed-in shell with fixture data and no network calls. // 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 // 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. // checked by hand. Used by src/app/dev/feed/page.tsx.
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { DiscoverRailsView } from "@/components/DiscoverRails"; import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch"; import { ExpertSearchView } from "@/components/ExpertSearch";
@@ -31,6 +31,38 @@ export function FeedPreview() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const list = useRef<HTMLDivElement | null>(null); const list = useRef<HTMLDivElement | null>(null);
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
const [listEl, setListEl] = useState<HTMLDivElement | null>(null);
const listRef = useCallback((el: HTMLDivElement | null) => {
list.current = el;
setListEl(el);
}, []);
// Paging: two more fixture pages load when the sentinel scrolls into view,
// the same way Feed pages (the observer root is the list or the phone reel).
const sentinel = useRef<HTMLDivElement | null>(null);
const [page, setPage] = useState(0);
const [paging, setPaging] = useState(false);
const hasMore = page < 2;
useEffect(() => {
const el = sentinel.current;
if (!el || !hasMore || paging) return;
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setPaging(true);
window.setTimeout(() => {
const next = page + 1;
const items = FIXTURE_FEED_ITEMS.map((raw) => {
const it = raw as { post: { id: string } };
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
});
setPosts((cur) => [...cur, ...toCardPosts(items)]);
setPage(next);
setPaging(false);
}, 900);
}, { root: listEl, rootMargin: "400px" });
io.observe(el);
return () => io.disconnect();
}, [hasMore, paging, page, posts.length, listEl]);
// The comments calls answer from the fixtures; the preview never reaches the network. // The comments calls answer from the fixtures; the preview never reaches the network.
useEffect(() => { useEffect(() => {
@@ -91,8 +123,10 @@ export function FeedPreview() {
<FeedView <FeedView
posts={posts} posts={posts}
status="ready" status="ready"
loadingMore={loadingMore} loadingMore={loadingMore || paging}
listRef={list} hasMore={hasMore}
sentinelRef={sentinel}
listRef={listRef}
loadMoreFailed={false} loadMoreFailed={false}
newCount={newCount} newCount={newCount}
onRetry={() => {}} onRetry={() => {}}
+7 -3
View File
@@ -120,10 +120,14 @@
.feed .composer { margin: 0 14px 4px; } .feed .composer { margin: 0 14px 4px; }
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; } .feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
/* Single-column fallback (760px and below, until the Mobile milestone): lists get a usable height. */ /* 760px and below: the full-screen feed (spec "Mobile layout"). The feed
column fills the app frame and the reel inside it is the only scroller.
The side panels are hidden; the phone dock and sheets reach them later. */
@container app (max-width: 760px) { @container app (max-width: 760px) {
.bento-col .panel { flex: 0 0 auto; } .bento { display: block; padding: 0; overflow: hidden; }
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; } .bento-left,
.bento-right { display: none; }
.bento-center { height: 100%; }
} }
/* Account panel ---------------------------------------------------------- */ /* Account panel ---------------------------------------------------------- */
+231
View File
@@ -0,0 +1,231 @@
/*
* Phone feed: full-screen snapping slides (spec "Mobile layout" > "Full-screen
* feed", ADR 0001). Ported from the mockup's .reel / .slide / .sl-* rules with
* the `slide-` and `reel-` prefixes, tokens only. White, the dark fades and the
* dark glass over photos and video come from the --on-media / --media-* tokens.
* Only rendered at 760px and below (AppShell measures .app).
*/
@keyframes slide-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes slide-shimmer { from { background-position: 120% 0; } to { background-position: -100% 0; } }
.reel-feed {
position: relative;
height: 100%;
overflow: hidden;
background: var(--bg);
}
.reel {
position: absolute;
inset: 0;
overflow-y: auto;
scroll-snap-type: y mandatory;
overscroll-behavior: contain;
scrollbar-width: none;
}
.reel::-webkit-scrollbar { display: none; }
.slide {
--slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg);
position: relative;
height: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
overflow: hidden;
background: var(--slide-text-bg);
color: var(--ink);
}
/* Media: edge to edge. Scaled to 104% until the slide is on screen, then eases to 100%. */
.slide-media { position: absolute; inset: 0; background: var(--tile-3); }
.slide-art {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.04);
transition: transform 6s ease-out;
}
.is-cur .slide-art { transform: scale(1); }
.slide-video .slide-art video,
.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
.slide-video .slide-art > div { height: 100%; }
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
.slide-play { z-index: 4; }
.slide-dur { top: 128px; right: 16px; z-index: 4; }
.slide-scrim,
.slide-scrim-top { position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; }
.slide-scrim { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); }
.slide-scrim-top { top: 0; height: 150px; background: linear-gradient(180deg, var(--media-fade-top), var(--media-fade-0)); }
/* Author and text over the bottom fade. */
.slide-meta {
position: absolute;
left: 20px;
right: 24px;
bottom: 108px;
z-index: 3;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.slide-meta > * { pointer-events: auto; }
.slide-on-media .slide-meta { color: var(--on-media); }
.slide-who { display: flex; align-items: center; gap: 12px; }
.slide-who > div { display: flex; flex-direction: column; min-width: 0; }
.slide-who strong { display: flex; align-items: center; font-size: 15px; font-weight: 700; }
.slide-who time { font-size: 12.5px; opacity: .72; }
.slide-on-media .slide-you { color: var(--on-media); box-shadow: inset 0 0 0 1px var(--on-media-badge); }
.slide-text {
margin: 0;
font-size: 15px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 12px var(--media-shadow);
}
.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; }
.slide-on-media .slide-error { color: var(--on-media); }
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark. */
.slide-quote-w { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.slide-quote {
position: absolute;
left: 24px;
right: 28px;
top: 46%;
transform: translateY(-50%);
margin: 0;
font-family: var(--font-serif), Georgia, serif;
font-size: 31px;
font-weight: 600;
line-height: 1.2;
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 8;
-webkit-box-orient: vertical;
overflow: hidden;
}
.slide-quote::before {
content: "\201C";
display: block;
margin-bottom: 6px;
color: var(--accent);
font-size: 84px;
line-height: .62;
}
/* The slide on screen: its content rises in when it snaps into place. */
.is-cur .slide-meta,
.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; }
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
.slide-menu { position: absolute; top: 70px; right: 12px; z-index: 6; }
.slide-menu-btn {
appearance: none;
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 14px;
color: var(--ink);
background: var(--glass);
-webkit-backdrop-filter: blur(16px) saturate(1.6);
backdrop-filter: blur(16px) saturate(1.6);
box-shadow: inset 0 0 0 1px var(--line);
cursor: pointer;
transition: transform .15s, background-color .2s;
}
.slide-menu-btn svg { width: 20px; height: 20px; }
.slide-menu-btn:active { transform: scale(.92); }
.slide-menu-btn:disabled { cursor: default; }
.slide-on-media .slide-menu-btn {
color: var(--on-media);
background: var(--media-glass);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
.slide-menu .post-pop { min-width: 230px; }
/* Loading slide: a skeleton card in the middle while the next page loads. */
.slide-load { background: var(--bg); }
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
.slide-load-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 0 22px 110px;
}
.slide-skel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--r-card);
}
.slide-skel-head { display: flex; align-items: center; gap: 12px; }
.slide-skel-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.slide-skel i {
display: block;
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, var(--tile-2) 0%, var(--tile-3) 40%, var(--tile-2) 80%);
background-size: 220% 100%;
animation: slide-shimmer 1.4s linear infinite;
}
.slide-skel .slide-skel-hex {
flex: none;
width: 46px;
height: 53px;
border-radius: 0;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
/* Empty feed or a failed page, as a slide. */
.slide-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 24px 110px;
text-align: center;
color: var(--ink-3);
}
/* New-posts pill floats over the top of the reel. */
.reel-pill {
position: absolute;
top: 76px;
left: 0;
right: 0;
z-index: 8;
display: flex;
justify-content: center;
pointer-events: none;
animation: feed-pill-in .45s var(--ease) backwards;
}
.reel-pill > * { pointer-events: auto; }
@media (prefers-reduced-motion: reduce) {
.is-cur .slide-meta,
.is-cur .slide-quote-w,
.slide-skel i,
.reel-pill { animation: none !important; }
.slide-art { transition: none !important; }
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { pickCurrent } from "@/components/MobileFeed";
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
import { toCardPosts } from "@/lib/feed-model";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({
type: "post",
publishedAt: "2026-10-19T11:00:00Z",
likesCount: 1,
commentsCount: 0,
post: { id, content: `text ${id}`, author: { id: authorId, displayName: `Author ${id}` }, mediaItems },
});
const POSTS = toCardPosts([
item("t1", "me"),
item("i1", "a2", [{ mediaType: "image", imageUrl: "https://img/x.jpg", order: 0 }]),
item("v1", "a3", [{ mediaType: "video", thumbnailUrl: "https://img/v.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4", videoDuration: 61, order: 0 }]),
]);
function render(over: Partial<FeedViewProps>, compact = true) {
const props: FeedViewProps = {
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, feedUserId: "me", onDelete: () => {}, ...over,
};
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
}
describe("compact layout", () => {
it("switches at 760px and below", () => {
expect(isCompactWidth(390)).toBe(true);
expect(isCompactWidth(760)).toBe(true);
expect(isCompactWidth(761)).toBe(false);
expect(isCompactWidth(1440)).toBe(false);
expect(isCompactWidth(0)).toBe(false);
});
it("keeps the desktop panel outside the phone layout", () => {
const html = render({}, false);
expect(html).toContain('data-testid="post-card"');
expect(html).not.toContain("feed-reel");
});
});
describe("mobile feed", () => {
it("renders one slide per post, text as a quote and media under the fades", () => {
const html = render({});
expect(html.match(/data-testid="feed-slide"/g)).toHaveLength(3);
expect(html).toContain('class="slide-quote">text t1</p>');
expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/);
expect(html).toContain("slide-scrim-top");
expect(html).toContain('class="slide-text">text i1</p>');
expect(html).toContain('aria-label="Play video"');
// The quote slide shows its text once, as the quote.
expect(html.match(/text t1/g)).toHaveLength(1);
});
it("marks the first slide as the one on screen", () => {
const html = render({});
expect(html).toMatch(/class="slide slide-on-text is-cur"/);
expect(html).toContain('data-current="0"');
});
it("shows the frosted menu only on the person's own posts", () => {
const html = render({});
expect(html.match(/slide-menu-btn/g)).toHaveLength(1);
expect(html).toContain('aria-label="More actions"');
expect(render({ feedUserId: "someone-else" })).not.toContain("slide-menu-btn");
});
it("ends with a loading slide while another page can load", () => {
expect(render({ hasMore: true })).toContain('data-testid="feed-slide-load"');
expect(render({ loadingMore: true })).toContain('data-testid="feed-slide-load"');
expect(render({})).not.toContain("feed-slide-load");
const failed = render({ hasMore: true, loadMoreFailed: true });
expect(failed).not.toContain("feed-slide-load");
expect(failed).toContain("Try again");
});
it("shows the loading slide on first load", () => {
expect(render({ status: "loading", posts: [] })).toContain('data-testid="feed-slide-load"');
});
it("picks the slide that is mostly on screen", () => {
expect(pickCurrent(0, [])).toBe(0);
expect(pickCurrent(0, [1])).toBe(1);
expect(pickCurrent(2, [1, 2])).toBe(2);
expect(pickCurrent(4, [1, 2])).toBe(1);
});
});
describe("mobile stylesheet", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
it("snaps one slide per screen", () => {
expect(css).toMatch(/\.reel \{[^}]*scroll-snap-type: y mandatory/);
expect(css).toMatch(/\.slide \{[^}]*height: 100%;[^}]*scroll-snap-stop: always/);
});
it("uses a 31px serif quote with a crimson mark, and eases media from 104%", () => {
expect(css).toMatch(/\.slide-quote \{[^}]*font-size: 31px/);
expect(css).toMatch(/\.slide-quote::before \{[^}]*color: var\(--accent\)/);
expect(css).toMatch(/transform: scale\(1\.04\)/);
expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/);
});
it("uses tokens only", () => {
expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/);
});
});
describe("one step per gesture", () => {
it("moves one slide in the gesture's direction and stays inside the reel", async () => {
const { stepTarget } = await import("@/components/MobileFeed");
expect(stepTarget(0, 120, 5)).toBe(1);
expect(stepTarget(3, -4, 5)).toBe(2);
expect(stepTarget(4, 300, 5)).toBe(4);
expect(stepTarget(0, -50, 5)).toBe(0);
expect(stepTarget(2, 0, 5)).toBe(2);
});
});