From 5e391ae6786ddc63e9cbb7acbbf2b63c2759d6f6 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 22:24:38 -0600 Subject: [PATCH] h8r4npp3p: Add the new-posts pill, feed list motion and app-wide reduced-motion switch-off --- src/app/globals.css | 25 +++++++++++++++ src/app/layout.tsx | 1 + src/components/Feed.tsx | 3 +- src/components/FeedView.tsx | 34 ++++++++++++++------ src/components/PostCard.tsx | 8 +++-- src/dev/FeedPreview.tsx | 26 ++++++++++++--- src/styles/feed.css | 63 +++++++++++++++++++++++++++++++++++++ src/styles/legacy.css | 7 ----- src/styles/post.css | 3 ++ tests/feed-view.test.ts | 6 ++-- 10 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 src/styles/feed.css 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 ? ( - +
+ +
) : 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 ( + <> +
+ + +
{}} - 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"); });