Promote to main: Feed browser luxury redesign #7
@@ -92,3 +92,28 @@
|
|||||||
}
|
}
|
||||||
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
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) {
|
||||||
|
.post,
|
||||||
|
.post-cm,
|
||||||
|
.post-pop,
|
||||||
|
.feed-pill,
|
||||||
|
.feed-skel::after,
|
||||||
|
.post-like-beat .post-heart,
|
||||||
|
.btn-spark *,
|
||||||
|
.btn-spark::before,
|
||||||
|
.btn-spark::after,
|
||||||
|
.btn-gloss::before,
|
||||||
|
.btn-gloss::after,
|
||||||
|
.btn-sweep::before,
|
||||||
|
.account-dot { animation: none !important; }
|
||||||
|
.btn-sweep::before,
|
||||||
|
.btn-gloss::after { transition: none !important; }
|
||||||
|
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
|
||||||
|
.spinner { animation-duration: 2.4s !important; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "../styles/avatar.css";
|
|||||||
import "../styles/signin.css";
|
import "../styles/signin.css";
|
||||||
import "../styles/post.css";
|
import "../styles/post.css";
|
||||||
import "../styles/composer.css";
|
import "../styles/composer.css";
|
||||||
|
import "../styles/feed.css";
|
||||||
import "../styles/legacy.css";
|
import "../styles/legacy.css";
|
||||||
|
|
||||||
const manrope = Manrope({
|
const manrope = Manrope({
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ export function Feed({
|
|||||||
function showNew() {
|
function showNew() {
|
||||||
setPosts((cur) => mergePosts(cur, pending));
|
setPosts((cur) => mergePosts(cur, pending));
|
||||||
setPending([]);
|
setPending([]);
|
||||||
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from "./Buttons";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
|
|
||||||
export type FeedStatus = "loading" | "ready" | "error";
|
export type FeedStatus = "loading" | "ready" | "error";
|
||||||
@@ -33,6 +34,24 @@ function Spinner() {
|
|||||||
return <span className="spinner" role="status" aria-label="Loading" />;
|
return <span className="spinner" role="status" aria-label="Loading" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shimmer placeholder card shown while the next page loads. */
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="feed-skel" role="status" aria-label="Loading more posts" data-testid="feed-skeleton">
|
||||||
|
<div className="feed-skel-head">
|
||||||
|
<span className="feed-skel-dot" />
|
||||||
|
<span className="feed-skel-lines">
|
||||||
|
<span className="feed-skel-line feed-skel-w40" />
|
||||||
|
<span className="feed-skel-line feed-skel-w25" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="feed-skel-line feed-skel-w100" />
|
||||||
|
<span className="feed-skel-line feed-skel-w80" />
|
||||||
|
<span className="feed-skel-media" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) {
|
function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="tile panel feed" aria-labelledby="h-feed">
|
<section className="tile panel feed" aria-labelledby="h-feed">
|
||||||
@@ -69,16 +88,17 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null}>
|
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null}>
|
||||||
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
|
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
|
||||||
{p.newCount > 0 ? (
|
{p.newCount > 0 ? (
|
||||||
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
<div className="feed-pill">
|
||||||
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||||
</button>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{p.posts.length === 0 ? (
|
{p.posts.length === 0 ? (
|
||||||
<p className="feed-message">No posts yet.</p>
|
<p className="feed-message">No posts yet.</p>
|
||||||
) : (
|
) : (
|
||||||
p.posts.map((post) => <PostCard
|
p.posts.map((post, i) => <PostCard
|
||||||
key={post.key}
|
key={post.key}
|
||||||
post={post}
|
post={post}
|
||||||
|
index={i}
|
||||||
now={p.now}
|
now={p.now}
|
||||||
onToggleLike={p.onToggleLike}
|
onToggleLike={p.onToggleLike}
|
||||||
likeBusy={p.likeBusy?.has(post.key)}
|
likeBusy={p.likeBusy?.has(post.key)}
|
||||||
@@ -96,11 +116,7 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{p.loadingMore ? (
|
{p.loadingMore ? <SkeletonCard /> : null}
|
||||||
<div className="feed-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</FeedPanel>
|
</FeedPanel>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { HexAvatar } from "@/components/HexAvatar";
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
import { CommentsSection } from "@/components/CommentsSection";
|
import { CommentsSection } from "@/components/CommentsSection";
|
||||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||||
@@ -10,6 +10,8 @@ import { formatDuration, relativeTime, type CardMedia, type CardPost } from "@/l
|
|||||||
export interface PostCardProps {
|
export interface PostCardProps {
|
||||||
post: CardPost;
|
post: CardPost;
|
||||||
now?: number;
|
now?: number;
|
||||||
|
/** Position in the list; the first cards rise in one after another. */
|
||||||
|
index?: number;
|
||||||
onToggleLike?: (post: CardPost) => void;
|
onToggleLike?: (post: CardPost) => void;
|
||||||
likeBusy?: boolean;
|
likeBusy?: boolean;
|
||||||
likeError?: boolean;
|
likeError?: boolean;
|
||||||
@@ -89,7 +91,7 @@ function PostVideo({ media, author }: { media: CardMedia; author: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
@@ -111,7 +113,7 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
}, [menu]);
|
}, [menu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="post" data-testid="post-card">
|
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
|
||||||
<header className="post-head">
|
<header className="post-head">
|
||||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||||
<div className="post-who">
|
<div className="post-who">
|
||||||
|
|||||||
+22
-4
@@ -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, useState } from "react";
|
import { 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";
|
||||||
@@ -26,6 +26,10 @@ export function FeedPreview() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [category, setCategory] = useState<string | null>(null);
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
// Fixture switches for the new-posts pill and the loading-more skeleton.
|
||||||
|
const [newCount, setNewCount] = useState(0);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const list = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@@ -60,6 +64,15 @@ export function FeedPreview() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ position: "fixed", left: "50%", bottom: 8, zIndex: 50, display: "flex", gap: 12, padding: "6px 10px", font: "12px sans-serif", background: "var(--tile-2)", color: "var(--ink)", border: "1px solid var(--line-2)", borderRadius: 8 }}>
|
||||||
|
<label style={{ margin: 0, fontSize: 12 }}>
|
||||||
|
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
|
||||||
|
</label>
|
||||||
|
<label style={{ margin: 0, fontSize: 12 }}>
|
||||||
|
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<AppShell
|
<AppShell
|
||||||
person={FIXTURE_PERSON}
|
person={FIXTURE_PERSON}
|
||||||
search={
|
search={
|
||||||
@@ -77,11 +90,15 @@ export function FeedPreview() {
|
|||||||
<FeedView
|
<FeedView
|
||||||
posts={posts}
|
posts={posts}
|
||||||
status="ready"
|
status="ready"
|
||||||
loadingMore={false}
|
loadingMore={loadingMore}
|
||||||
|
listRef={list}
|
||||||
loadMoreFailed={false}
|
loadMoreFailed={false}
|
||||||
newCount={0}
|
newCount={newCount}
|
||||||
onRetry={() => {}}
|
onRetry={() => {}}
|
||||||
onShowNew={() => {}}
|
onShowNew={() => {
|
||||||
|
setNewCount(0);
|
||||||
|
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}}
|
||||||
onToggleLike={toggleLike}
|
onToggleLike={toggleLike}
|
||||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||||
@@ -104,5 +121,6 @@ export function FeedPreview() {
|
|||||||
}
|
}
|
||||||
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
|
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Feed list: scroll snap, the new-posts pill and the loading-more skeleton
|
||||||
|
* (spec "Feed list", ADR 0001). Prefixed `feed-` classes, tokens only.
|
||||||
|
*/
|
||||||
|
.feed .feed-list { scroll-snap-type: y proximity; }
|
||||||
|
|
||||||
|
.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
|
||||||
|
.feed-message button { margin-top: 8px; }
|
||||||
|
.feed-center { display: grid; place-items: center; padding: 16px; }
|
||||||
|
.sentinel { height: 1px; }
|
||||||
|
|
||||||
|
.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); } }
|
||||||
|
|
||||||
|
/* The pill floats at the top center of the list. The zero-height sticky
|
||||||
|
wrapper takes the entrance animation, so the button keeps its own transforms
|
||||||
|
(hover expand, press). */
|
||||||
|
.feed-pill {
|
||||||
|
position: sticky;
|
||||||
|
top: 12px;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: 0;
|
||||||
|
margin-bottom: -16px;
|
||||||
|
overflow: visible;
|
||||||
|
animation: feed-pill-in .45s var(--ease) backwards;
|
||||||
|
}
|
||||||
|
.feed-pill .btn-grow { flex: none; }
|
||||||
|
@keyframes feed-pill-in { from { opacity: 0; transform: translateY(-10px) scale(.96); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* Skeleton card: same footprint as a post, with a shimmer band sweeping over it. */
|
||||||
|
.feed-skel {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
scroll-snap-align: none;
|
||||||
|
}
|
||||||
|
.feed-skel::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--ink), transparent 90%) 50%, transparent 80%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: feed-shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes feed-shimmer { to { transform: translateX(100%); } }
|
||||||
|
.feed-skel-head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.feed-skel-lines { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||||
|
.feed-skel-dot { width: 40px; height: 40px; border-radius: 50%; background: var(--tile-3); flex: none; }
|
||||||
|
.feed-skel-line { display: block; height: 12px; border-radius: 6px; background: var(--tile-3); }
|
||||||
|
.feed-skel-w25 { width: 25%; }
|
||||||
|
.feed-skel-w40 { width: 40%; }
|
||||||
|
.feed-skel-w80 { width: 80%; }
|
||||||
|
.feed-skel-w100 { width: 100%; }
|
||||||
|
.feed-skel-media { display: block; aspect-ratio: 16 / 9; border-radius: 12px; background: var(--tile-3); }
|
||||||
@@ -9,13 +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; }
|
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; }
|
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; }
|
.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; }
|
||||||
.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
|
|
||||||
.feed-message button { margin-top: 8px; }
|
|
||||||
.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: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
|
||||||
.sentinel { height: 1px; }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r-card);
|
border-radius: var(--r-card);
|
||||||
animation: post-rise .55s var(--ease) both;
|
animation: post-rise .55s var(--ease) both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 70ms);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-margin-top: 16px;
|
||||||
transition: border-color .3s, box-shadow .3s;
|
transition: border-color .3s, box-shadow .3s;
|
||||||
}
|
}
|
||||||
.post:hover { border-color: var(--line-2); box-shadow: var(--shadow-tile); }
|
.post:hover { border-color: var(--line-2); box-shadow: var(--shadow-tile); }
|
||||||
|
|||||||
@@ -115,9 +115,11 @@ describe("FeedView", () => {
|
|||||||
expect(html).toContain("Try again");
|
expect(html).toContain("Try again");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spinner while loading more and a new-posts control", () => {
|
it("skeleton card while loading more and the new-posts pill", () => {
|
||||||
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
|
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
|
||||||
expect(html).toContain('class="spinner"');
|
expect(html).toContain('data-testid="feed-skeleton"');
|
||||||
|
expect(html).not.toContain('class="spinner"');
|
||||||
|
expect(html).toContain("btn-grow");
|
||||||
expect(html).toContain("2 new posts");
|
expect(html).toContain("2 new posts");
|
||||||
expect(html).not.toContain("Load more");
|
expect(html).not.toContain("Load more");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user