Promote to main: Feed browser luxury redesign #7
@@ -92,3 +92,28 @@
|
||||
}
|
||||
: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/post.css";
|
||||
import "../styles/composer.css";
|
||||
import "../styles/feed.css";
|
||||
import "../styles/legacy.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
|
||||
@@ -236,7 +236,8 @@ export function Feed({
|
||||
function showNew() {
|
||||
setPosts((cur) => mergePosts(cur, pending));
|
||||
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 (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||
import { Composer, type ComposerProps } from "./Composer";
|
||||
import { GrowButton } from "./Buttons";
|
||||
import { PostCard } from "./PostCard";
|
||||
|
||||
export type FeedStatus = "loading" | "ready" | "error";
|
||||
@@ -33,6 +34,24 @@ function Spinner() {
|
||||
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 }) {
|
||||
return (
|
||||
<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}>
|
||||
<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>
|
||||
<div className="feed-pill">
|
||||
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||
</div>
|
||||
) : null}
|
||||
{p.posts.length === 0 ? (
|
||||
<p className="feed-message">No posts yet.</p>
|
||||
) : (
|
||||
p.posts.map((post) => <PostCard
|
||||
p.posts.map((post, i) => <PostCard
|
||||
key={post.key}
|
||||
post={post}
|
||||
index={i}
|
||||
now={p.now}
|
||||
onToggleLike={p.onToggleLike}
|
||||
likeBusy={p.likeBusy?.has(post.key)}
|
||||
@@ -96,11 +116,7 @@ export function FeedView(p: FeedViewProps) {
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? (
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? <SkeletonCard /> : null}
|
||||
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||
</div>
|
||||
</FeedPanel>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { HexAvatar } from "@/components/HexAvatar";
|
||||
import { CommentsSection } from "@/components/CommentsSection";
|
||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||
@@ -10,6 +10,8 @@ import { formatDuration, relativeTime, type CardMedia, type CardPost } from "@/l
|
||||
export interface PostCardProps {
|
||||
post: CardPost;
|
||||
now?: number;
|
||||
/** Position in the list; the first cards rise in one after another. */
|
||||
index?: number;
|
||||
onToggleLike?: (post: CardPost) => void;
|
||||
likeBusy?: 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 [menu, setMenu] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
@@ -111,7 +113,7 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
||||
}, [menu]);
|
||||
|
||||
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">
|
||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||
<div className="post-who">
|
||||
|
||||
+22
-4
@@ -3,7 +3,7 @@
|
||||
// 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 { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||
@@ -26,6 +26,10 @@ export function FeedPreview() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -60,6 +64,15 @@ export function FeedPreview() {
|
||||
);
|
||||
|
||||
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
|
||||
person={FIXTURE_PERSON}
|
||||
search={
|
||||
@@ -77,11 +90,15 @@ export function FeedPreview() {
|
||||
<FeedView
|
||||
posts={posts}
|
||||
status="ready"
|
||||
loadingMore={false}
|
||||
loadingMore={loadingMore}
|
||||
listRef={list}
|
||||
loadMoreFailed={false}
|
||||
newCount={0}
|
||||
newCount={newCount}
|
||||
onRetry={() => {}}
|
||||
onShowNew={() => {}}
|
||||
onShowNew={() => {
|
||||
setNewCount(0);
|
||||
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
onToggleLike={toggleLike}
|
||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||
@@ -104,5 +121,6 @@ export function FeedPreview() {
|
||||
}
|
||||
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; }
|
||||
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; }
|
||||
.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-radius: var(--r-card);
|
||||
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;
|
||||
}
|
||||
.post:hover { border-color: var(--line-2); box-shadow: var(--shadow-tile); }
|
||||
|
||||
@@ -115,9 +115,11 @@ describe("FeedView", () => {
|
||||
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 });
|
||||
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).not.toContain("Load more");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user