h8r4npp3p: Add the new-posts pill, feed list motion and app-wide reduced-motion switch-off

This commit is contained in:
2026-10-19 22:24:38 -06:00
parent f250cdf09c
commit 5e391ae678
10 changed files with 150 additions and 26 deletions
+25
View File
@@ -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; }
}
+1
View File
@@ -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({
+2 -1
View File
@@ -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 (
+25 -9
View File
@@ -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>
+5 -3
View File
@@ -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
View File
@@ -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} />}
/>
</>
);
}
+63
View File
@@ -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); }
-7
View File
@@ -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; }
+3
View File
@@ -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); }
+4 -2
View File
@@ -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");
});