diff --git a/src/app/globals.css b/src/app/globals.css
index 053314b..41f6236 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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; }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 9131bc9..c89c674 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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({
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx
index ae0dfd1..cf72f82 100644
--- a/src/components/Feed.tsx
+++ b/src/components/Feed.tsx
@@ -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 (
diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx
index 85ac004..34c867d 100644
--- a/src/components/FeedView.tsx
+++ b/src/components/FeedView.tsx
@@ -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 ;
}
+/** Shimmer placeholder card shown while the next page loads. */
+function SkeletonCard() {
+ return (
+
+ );
+}
+
function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) {
return (
@@ -69,16 +88,17 @@ export function FeedView(p: FeedViewProps) {
: null}>
{p.newCount > 0 ? (
-
- {p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
-
+
+
+
) : null}
{p.posts.length === 0 ? (
No posts yet.
) : (
- p.posts.map((post) =>
Try again
) : null}
- {p.loadingMore ? (
-
-
-
- ) : null}
+ {p.loadingMore ? : null}
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx
index 64189e8..57db693 100644
--- a/src/components/PostCard.tsx
+++ b/src/components/PostCard.tsx
@@ -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 (
-
+
diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx
index a9ced78..0708ce8 100644
--- a/src/dev/FeedPreview.tsx
+++ b/src/dev/FeedPreview.tsx
@@ -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
(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState(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(null);
// The comments calls answer from the fixtures; the preview never reaches the network.
useEffect(() => {
@@ -60,6 +64,15 @@ export function FeedPreview() {
);
return (
+ <>
+
+
+ 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
+
+
+ setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
+
+
{}}
- 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={ }
/>
+ >
);
}
diff --git a/src/styles/feed.css b/src/styles/feed.css
new file mode 100644
index 0000000..c3efaed
--- /dev/null
+++ b/src/styles/feed.css
@@ -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); }
diff --git a/src/styles/legacy.css b/src/styles/legacy.css
index d89b02b..bd0d65d 100644
--- a/src/styles/legacy.css
+++ b/src/styles/legacy.css
@@ -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; }
diff --git a/src/styles/post.css b/src/styles/post.css
index 9291365..a82a28d 100644
--- a/src/styles/post.css
+++ b/src/styles/post.css
@@ -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); }
diff --git a/tests/feed-view.test.ts b/tests/feed-view.test.ts
index a82437d..2b5a6a4 100644
--- a/tests/feed-view.test.ts
+++ b/tests/feed-view.test.ts
@@ -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");
});