diff --git a/src/app/globals.css b/src/app/globals.css index 94cda99..bbc11b1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -62,6 +62,15 @@ --accent-glow-0: rgba(255,110,110,0); /* Text and marks on a crimson face: white in both themes. */ --on-accent: #FFFFFF; + /* Over photos and video (mobile slides): white text, dark fades and dark glass, same in both themes. */ + --on-media: #FFFFFF; + --on-media-line: rgba(255,255,255,.18); + --on-media-badge: rgba(255,255,255,.5); + --media-fade: rgba(0,0,0,.8); + --media-fade-top: rgba(0,0,0,.45); + --media-fade-0: rgba(0,0,0,0); + --media-glass: rgba(0,0,0,.28); + --media-shadow: rgba(0,0,0,.35); /* HexAvatar initials: dark muted gradients, same in both themes. */ --hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 95a647b..ea58f0d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,6 +14,7 @@ import "../styles/featured.css"; import "../styles/feed.css"; import "../styles/trending.css"; import "../styles/search.css"; +import "../styles/mobile.css"; import "../styles/legacy.css"; const manrope = Manrope({ diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 36935be..877b39a 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,10 +1,11 @@ "use client"; -import type { ReactNode } from "react"; +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import type { Person } from "@/lib/auth-client"; import { useTheme } from "@/lib/use-theme"; import { HexAvatar } from "./HexAvatar"; import { Logo } from "./Logo"; +import { CompactLayoutContext, isCompactWidth } from "./layout-context"; const iconProps = { viewBox: "0 0 24 24", @@ -73,8 +74,25 @@ export interface AppShellProps { /** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */ export function AppShell({ person, search, feed, discover }: AppShellProps) { + const root = useRef(null); + const [compact, setCompact] = useState(false); + + // The CSS switches layout with a container query on .app; the feed also + // needs to know, because the phone feed is different markup (full-screen slides). + useLayoutEffect(() => { + const el = root.current; + if (!el) return; + const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width)); + measure(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return ( -
+ +
@@ -86,5 +104,6 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
+
); } diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 3dafa9b..7d359f6 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -8,6 +8,7 @@ import { PostError, publishAndConfirm } from "@/lib/post-client"; import { DeleteError, sendDelete } from "@/lib/delete-client"; import type { ComposerProps } from "./Composer"; import { FeedView, type FeedStatus } from "./FeedView"; +import { useCompactLayout } from "./layout-context"; const POLL_MS = 45_000; @@ -44,6 +45,9 @@ export function Feed({ const list = useRef(null); const busy = useRef(false); const shown = useRef>(new Set()); + // The list element changes between the panel and the phone reel; the + // infinite-scroll observer re-attaches to the new one. + const compact = useCompactLayout(); useEffect(() => { shown.current = new Set(posts.map((p) => p.key)); @@ -117,7 +121,7 @@ export function Feed({ ); io.observe(el); return () => io.disconnect(); - }, [nextCursor, status, loadMoreFailed, loadMore, posts.length]); + }, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]); // Real-time: poll the first page while the tab is visible. useEffect(() => { @@ -276,6 +280,7 @@ export function Feed({ deleteErrors={deleteErrors} onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }} + hasMore={!!nextCursor} sentinelRef={sentinel} listRef={list} /> diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 99e4d2c..0a2fbc6 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -1,8 +1,12 @@ +"use client"; + import type { ReactNode, Ref } from "react"; import { canDelete, type CardPost } from "@/lib/feed-model"; import { Composer, type ComposerProps } from "./Composer"; import { GrowButton, IconButton } from "./Buttons"; import { PostCard } from "./PostCard"; +import { useCompactLayout } from "./layout-context"; +import { MobileFeed, type CurrentSlide } from "./MobileFeed"; export type FeedStatus = "loading" | "ready" | "error"; @@ -33,6 +37,10 @@ export interface FeedViewProps { /** The scrolling feed list; the page itself never scrolls. */ listRef?: Ref; now?: number; + /** Another page can load; the phone feed ends with a loading slide. */ + hasMore?: boolean; + /** Phone layout only: the slide on screen changed (for the dock). */ + onCurrentChange?: (slide: CurrentSlide) => void; } function Spinner() { @@ -104,6 +112,9 @@ function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { chi } export function FeedView(p: FeedViewProps) { + // At 760px and below the feed is the full-screen snap reel instead of the panel. + const compact = useCompactLayout(); + if (compact) return ; if (p.status === "loading") { return ( diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx new file mode 100644 index 0000000..0e9661c --- /dev/null +++ b/src/components/MobileFeed.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useEffect, useRef, useState, type Ref } from "react"; +import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model"; +import { GrowButton } from "./Buttons"; +import { HexAvatar } from "./HexAvatar"; +import { PlayIcon, PostMenu } from "./PostCard"; +import { VideoPlayer } from "./VideoPlayer"; +import type { FeedViewProps } from "./FeedView"; + +/** The slide on screen: its index in the reel and its post (null on the loading slide). */ +export interface CurrentSlide { + index: number; + post: CardPost | null; +} + +/** Share of a slide that must be on screen before it counts as the current one. */ +const CURRENT_RATIO = 0.6; + +/** Smallest finger travel, in px, that counts as a swipe to the next or previous slide. */ +const SWIPE_MIN = 40; +/** Wheel steps closer together than this belong to one gesture (trackpad inertia). */ +const WHEEL_QUIET_MS = 220; +/** A wheel gesture moves at most one slide in this time. */ +const WHEEL_STEP_MS = 520; + +/** The slide one step away from `from` in the direction of `delta`, kept inside the reel. */ +export function stepTarget(from: number, delta: number, count: number): number { + if (!delta || count <= 0) return Math.min(Math.max(from, 0), Math.max(count - 1, 0)); + return Math.min(Math.max(from + Math.sign(delta), 0), count - 1); +} + +/** Index of the slide on screen, given which slides are now mostly visible. Keeps the old one when none is. */ +export function pickCurrent(previous: number, visible: number[]): number { + if (!visible.length) return previous; + return visible.includes(previous) ? previous : Math.min(...visible); +} + +function setRef(ref: Ref | undefined, value: T | null) { + if (typeof ref === "function") ref(value); + else if (ref) (ref as { current: T | null }).current = value; +} + +function SlideMedia({ media, author }: { media: CardMedia; author: string }) { + const box = useRef(null); + const [started, setStarted] = useState(false); + const playable = isPlayableVideo(media); + if (playable) { + const duration = formatDuration(media.durationSeconds); + const start = () => { + setStarted(true); + void box.current?.querySelector("video")?.play().catch(() => {}); + }; + return ( +
+
+ +
+ {started ? null : ( + <> + + {duration ? {duration} : null} + + )} +
+ ); + } + if (media.src) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {media.kind +
+ ); + } + return ( +
+ Video is processing +
+ ); +} + +function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) { + const media = post.media.find((m) => m.kind === "video" || m.src) ?? null; + const own = canDelete(post, p.feedUserId) && !!p.onDelete; + const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}`; + return ( +
+ {media ? ( + <> + +
+ ); +} + +function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref }) { + return ( +
+
+ ); +} + +/** + * Phone feed (spec "Mobile layout" > "Full-screen feed"): one post per screen, + * snapping. Uses the same posts, delete and paging as the desktop list: the + * reel is the list ref (the infinite-scroll root) and the loading slide holds + * the sentinel. Reports the slide on screen through onCurrentChange. + */ +export function MobileFeed(p: FeedViewProps) { + const reel = useRef(null); + const [current, setCurrent] = useState(0); + const onCurrentChange = p.onCurrentChange; + const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore)); + const slideCount = (p.status === "ready" ? p.posts.length : 0) + (showLoad ? 1 : 0); + + // Track the slide on screen. A fresh observer per change picks up new slides. + useEffect(() => { + const root = reel.current; + if (!root || typeof IntersectionObserver === "undefined") return; + const ratios = new Map(); + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio); + const visible = [...ratios].filter(([, r]) => r >= CURRENT_RATIO).map(([i]) => i); + setCurrent((prev) => pickCurrent(prev, visible)); + }, + { root, threshold: [0, CURRENT_RATIO, 1] }, + ); + root.querySelectorAll(":scope > .slide").forEach((el) => io.observe(el)); + return () => io.disconnect(); + }, [slideCount]); + + // One wheel notch or one swipe moves exactly one slide. Native snapping alone + // sends a short or slow gesture back to the slide it started on. + useEffect(() => { + const root = reel.current; + if (!root) return; + const count = () => root.querySelectorAll(":scope > .slide").length; + const at = () => Math.round(root.scrollTop / Math.max(root.clientHeight, 1)); + const go = (to: number) => { + const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + root.scrollTo({ top: to * root.clientHeight, behavior: calm ? "auto" : "smooth" }); + }; + let lastWheel = 0; + let lastStep = 0; + let from = 0; + const onWheel = (e: WheelEvent) => { + if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; + e.preventDefault(); + const now = performance.now(); + const fresh = now - lastWheel > WHEEL_QUIET_MS && now - lastStep > WHEEL_STEP_MS; + lastWheel = now; + if (!fresh || !e.deltaY) return; + lastStep = now; + go(stepTarget(at(), e.deltaY, count())); + }; + let startY: number | null = null; + const onTouchStart = (e: TouchEvent) => { + startY = e.touches.length === 1 ? e.touches[0].clientY : null; + from = at(); + }; + const onTouchEnd = (e: TouchEvent) => { + if (startY === null || e.touches.length) return; + const dy = startY - (e.changedTouches[0]?.clientY ?? startY); + startY = null; + if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count())); + }; + root.addEventListener("wheel", onWheel, { passive: false }); + root.addEventListener("touchstart", onTouchStart, { passive: true }); + root.addEventListener("touchend", onTouchEnd, { passive: true }); + return () => { + root.removeEventListener("wheel", onWheel); + root.removeEventListener("touchstart", onTouchStart); + root.removeEventListener("touchend", onTouchEnd); + }; + }, []); + + // Keep the index inside the list when posts are removed. + const index = Math.min(current, Math.max(0, slideCount - 1)); + const currentPost = p.status === "ready" ? p.posts[index] ?? null : null; + + useEffect(() => { + onCurrentChange?.({ index, post: currentPost }); + }, [index, currentPost, onCurrentChange]); + + // A video keeps playing only while its slide is on screen. + useEffect(() => { + reel.current?.querySelectorAll(":scope > .slide").forEach((el) => { + if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause()); + }); + }, [index]); + + return ( +
+ {p.newCount > 0 && p.status === "ready" ? ( +
+ +
+ ) : null} +
{ + reel.current = el; + setRef(p.listRef, el); + }} + > + {p.status === "error" ? ( +
+

The feed did not load.

+ +
+ ) : null} + {p.status === "ready" && p.posts.length === 0 && !showLoad ? ( +
+

No posts yet.

+
+ ) : null} + {p.status === "ready" + ? p.posts.map((post, i) => ) + : null} + {p.status === "ready" && p.loadMoreFailed ? ( +
+

The feed did not load.

+ +
+ ) : null} + {showLoad ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 247cc1e..21e4d9a 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -59,7 +59,7 @@ function TrashIcon() { ); } -function PlayIcon() { +export function PlayIcon() { return (
+ + {menu ? ( +
+ {confirming ? ( +
+

Delete this post?

+

This cannot be undone.

+
+ { setConfirming(false); setMenu(false); }} + /> + } + size="sm" + disabled={deleteBusy} + onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }} + /> +
+
+ ) : ( + + )} +
+ ) : null} +
+ ); +} + +export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) { + const [open, setOpen] = useState(false); + const [beat, setBeat] = useState(false); + const own = !!(canDelete && onDelete); + return (
@@ -123,53 +185,7 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
- {own ? ( -
- - {menu ? ( -
- {confirming ? ( -
-

Delete this post?

-

This cannot be undone.

-
- { setConfirming(false); setMenu(false); }} - /> - } - size="sm" - disabled={deleteBusy} - onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }} - /> -
-
- ) : ( - - )} -
- ) : null} -
- ) : null} + {own ? : null} {deleteError ?

Could not delete the post.

: null} {post.text ?

{post.text}

: null} diff --git a/src/components/layout-context.ts b/src/components/layout-context.ts new file mode 100644 index 0000000..1aa9449 --- /dev/null +++ b/src/components/layout-context.ts @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, useContext } from "react"; + +/** Widest `.app` width that gets the phone layout (spec "Mobile layout", ADR 0001 section 3). */ +export const COMPACT_MAX_WIDTH = 760; + +/** True when `.app` is 760px wide or less. Matches `@container app (max-width: 760px)`. */ +export function isCompactWidth(width: number): boolean { + return width > 0 && width <= COMPACT_MAX_WIDTH; +} + +/** + * Whether the signed-in shell is in the phone layout. AppShell measures `.app` + * and provides it; outside the shell (tests, other pages) it is false, so the + * desktop markup renders. + */ +export const CompactLayoutContext = createContext(false); + +export function useCompactLayout(): boolean { + return useContext(CompactLayoutContext); +} diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx index 3a1d4e2..3ef5020 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, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { DiscoverRailsView } from "@/components/DiscoverRails"; import { ExpertSearchView } from "@/components/ExpertSearch"; @@ -31,6 +31,38 @@ export function FeedPreview() { const [loadingMore, setLoadingMore] = useState(false); const [refreshing, setRefreshing] = useState(false); const list = useRef(null); + // The list element swaps between the panel and the phone reel; paging re-attaches to it. + const [listEl, setListEl] = useState(null); + const listRef = useCallback((el: HTMLDivElement | null) => { + list.current = el; + setListEl(el); + }, []); + // Paging: two more fixture pages load when the sentinel scrolls into view, + // the same way Feed pages (the observer root is the list or the phone reel). + const sentinel = useRef(null); + const [page, setPage] = useState(0); + const [paging, setPaging] = useState(false); + const hasMore = page < 2; + useEffect(() => { + const el = sentinel.current; + if (!el || !hasMore || paging) return; + const io = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return; + setPaging(true); + window.setTimeout(() => { + const next = page + 1; + const items = FIXTURE_FEED_ITEMS.map((raw) => { + const it = raw as { post: { id: string } }; + return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } }; + }); + setPosts((cur) => [...cur, ...toCardPosts(items)]); + setPage(next); + setPaging(false); + }, 900); + }, { root: listEl, rootMargin: "400px" }); + io.observe(el); + return () => io.disconnect(); + }, [hasMore, paging, page, posts.length, listEl]); // The comments calls answer from the fixtures; the preview never reaches the network. useEffect(() => { @@ -91,8 +123,10 @@ export function FeedPreview() { {}} diff --git a/src/styles/layout.css b/src/styles/layout.css index 8c807db..d4933c7 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -120,10 +120,14 @@ .feed .composer { margin: 0 14px 4px; } .feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; } -/* Single-column fallback (760px and below, until the Mobile milestone): lists get a usable height. */ +/* 760px and below: the full-screen feed (spec "Mobile layout"). The feed + column fills the app frame and the reel inside it is the only scroller. + The side panels are hidden; the phone dock and sheets reach them later. */ @container app (max-width: 760px) { - .bento-col .panel { flex: 0 0 auto; } - .bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; } + .bento { display: block; padding: 0; overflow: hidden; } + .bento-left, + .bento-right { display: none; } + .bento-center { height: 100%; } } /* Account panel ---------------------------------------------------------- */ diff --git a/src/styles/mobile.css b/src/styles/mobile.css new file mode 100644 index 0000000..60ea95f --- /dev/null +++ b/src/styles/mobile.css @@ -0,0 +1,231 @@ +/* + * Phone feed: full-screen snapping slides (spec "Mobile layout" > "Full-screen + * feed", ADR 0001). Ported from the mockup's .reel / .slide / .sl-* rules with + * the `slide-` and `reel-` prefixes, tokens only. White, the dark fades and the + * dark glass over photos and video come from the --on-media / --media-* tokens. + * Only rendered at 760px and below (AppShell measures .app). + */ +@keyframes slide-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } } +@keyframes slide-shimmer { from { background-position: 120% 0; } to { background-position: -100% 0; } } + +.reel-feed { + position: relative; + height: 100%; + overflow: hidden; + background: var(--bg); +} + +.reel { + position: absolute; + inset: 0; + overflow-y: auto; + scroll-snap-type: y mandatory; + overscroll-behavior: contain; + scrollbar-width: none; +} +.reel::-webkit-scrollbar { display: none; } + +.slide { + --slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg); + position: relative; + height: 100%; + scroll-snap-align: start; + scroll-snap-stop: always; + overflow: hidden; + background: var(--slide-text-bg); + color: var(--ink); +} + +/* Media: edge to edge. Scaled to 104% until the slide is on screen, then eases to 100%. */ +.slide-media { position: absolute; inset: 0; background: var(--tile-3); } +.slide-art { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + transform: scale(1.04); + transition: transform 6s ease-out; +} +.is-cur .slide-art { transform: scale(1); } +.slide-video .slide-art video, +.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; } +.slide-video .slide-art > div { height: 100%; } +.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; } +.slide-play { z-index: 4; } +.slide-dur { top: 128px; right: 16px; z-index: 4; } + +.slide-scrim, +.slide-scrim-top { position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; } +.slide-scrim { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); } +.slide-scrim-top { top: 0; height: 150px; background: linear-gradient(180deg, var(--media-fade-top), var(--media-fade-0)); } + +/* Author and text over the bottom fade. */ +.slide-meta { + position: absolute; + left: 20px; + right: 24px; + bottom: 108px; + z-index: 3; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; +} +.slide-meta > * { pointer-events: auto; } +.slide-on-media .slide-meta { color: var(--on-media); } +.slide-who { display: flex; align-items: center; gap: 12px; } +.slide-who > div { display: flex; flex-direction: column; min-width: 0; } +.slide-who strong { display: flex; align-items: center; font-size: 15px; font-weight: 700; } +.slide-who time { font-size: 12.5px; opacity: .72; } +.slide-on-media .slide-you { color: var(--on-media); box-shadow: inset 0 0 0 1px var(--on-media-badge); } +.slide-text { + margin: 0; + font-size: 15px; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-shadow: 0 1px 12px var(--media-shadow); +} +.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; } +.slide-on-media .slide-error { color: var(--on-media); } + +/* Text-only post: the text is the hero, a serif quote with a crimson opening mark. */ +.slide-quote-w { position: absolute; inset: 0; z-index: 2; pointer-events: none; } +.slide-quote { + position: absolute; + left: 24px; + right: 28px; + top: 46%; + transform: translateY(-50%); + margin: 0; + font-family: var(--font-serif), Georgia, serif; + font-size: 31px; + font-weight: 600; + line-height: 1.2; + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 8; + -webkit-box-orient: vertical; + overflow: hidden; +} +.slide-quote::before { + content: "\201C"; + display: block; + margin-bottom: 6px; + color: var(--accent); + font-size: 84px; + line-height: .62; +} + +/* The slide on screen: its content rises in when it snaps into place. */ +.is-cur .slide-meta, +.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; } + +/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */ +.slide-menu { position: absolute; top: 70px; right: 12px; z-index: 6; } +.slide-menu-btn { + appearance: none; + display: grid; + place-items: center; + width: 44px; + height: 44px; + margin: 0; + padding: 0; + border: 0; + border-radius: 14px; + color: var(--ink); + background: var(--glass); + -webkit-backdrop-filter: blur(16px) saturate(1.6); + backdrop-filter: blur(16px) saturate(1.6); + box-shadow: inset 0 0 0 1px var(--line); + cursor: pointer; + transition: transform .15s, background-color .2s; +} +.slide-menu-btn svg { width: 20px; height: 20px; } +.slide-menu-btn:active { transform: scale(.92); } +.slide-menu-btn:disabled { cursor: default; } +.slide-on-media .slide-menu-btn { + color: var(--on-media); + background: var(--media-glass); + box-shadow: inset 0 0 0 1px var(--on-media-line); +} +.slide-menu .post-pop { min-width: 230px; } + +/* Loading slide: a skeleton card in the middle while the next page loads. */ +.slide-load { background: var(--bg); } +.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; } +.slide-load-body { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 14px; + padding: 0 22px 110px; +} +.slide-skel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--r-card); +} +.slide-skel-head { display: flex; align-items: center; gap: 12px; } +.slide-skel-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; } +.slide-skel i { + display: block; + height: 12px; + border-radius: 6px; + background: linear-gradient(90deg, var(--tile-2) 0%, var(--tile-3) 40%, var(--tile-2) 80%); + background-size: 220% 100%; + animation: slide-shimmer 1.4s linear infinite; +} +.slide-skel .slide-skel-hex { + flex: none; + width: 46px; + height: 53px; + border-radius: 0; + clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); +} + +/* Empty feed or a failed page, as a slide. */ +.slide-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 24px 110px; + text-align: center; + color: var(--ink-3); +} + +/* New-posts pill floats over the top of the reel. */ +.reel-pill { + position: absolute; + top: 76px; + left: 0; + right: 0; + z-index: 8; + display: flex; + justify-content: center; + pointer-events: none; + animation: feed-pill-in .45s var(--ease) backwards; +} +.reel-pill > * { pointer-events: auto; } + +@media (prefers-reduced-motion: reduce) { + .is-cur .slide-meta, + .is-cur .slide-quote-w, + .slide-skel i, + .reel-pill { animation: none !important; } + .slide-art { transition: none !important; } +} diff --git a/tests/mobile-feed.test.ts b/tests/mobile-feed.test.ts new file mode 100644 index 0000000..8dc34ce --- /dev/null +++ b/tests/mobile-feed.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { FeedView, type FeedViewProps } from "@/components/FeedView"; +import { pickCurrent } from "@/components/MobileFeed"; +import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context"; +import { toCardPosts } from "@/lib/feed-model"; + +const NOW = Date.parse("2026-10-19T12:00:00Z"); +const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({ + type: "post", + publishedAt: "2026-10-19T11:00:00Z", + likesCount: 1, + commentsCount: 0, + post: { id, content: `text ${id}`, author: { id: authorId, displayName: `Author ${id}` }, mediaItems }, +}); +const POSTS = toCardPosts([ + item("t1", "me"), + item("i1", "a2", [{ mediaType: "image", imageUrl: "https://img/x.jpg", order: 0 }]), + item("v1", "a3", [{ mediaType: "video", thumbnailUrl: "https://img/v.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4", videoDuration: 61, order: 0 }]), +]); + +function render(over: Partial, compact = true) { + const props: FeedViewProps = { + posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false, + newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, feedUserId: "me", onDelete: () => {}, ...over, + }; + return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props))); +} + +describe("compact layout", () => { + it("switches at 760px and below", () => { + expect(isCompactWidth(390)).toBe(true); + expect(isCompactWidth(760)).toBe(true); + expect(isCompactWidth(761)).toBe(false); + expect(isCompactWidth(1440)).toBe(false); + expect(isCompactWidth(0)).toBe(false); + }); + + it("keeps the desktop panel outside the phone layout", () => { + const html = render({}, false); + expect(html).toContain('data-testid="post-card"'); + expect(html).not.toContain("feed-reel"); + }); +}); + +describe("mobile feed", () => { + it("renders one slide per post, text as a quote and media under the fades", () => { + const html = render({}); + expect(html.match(/data-testid="feed-slide"/g)).toHaveLength(3); + expect(html).toContain('class="slide-quote">text t1

'); + expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/); + expect(html).toContain("slide-scrim-top"); + expect(html).toContain('class="slide-text">text i1

'); + expect(html).toContain('aria-label="Play video"'); + // The quote slide shows its text once, as the quote. + expect(html.match(/text t1/g)).toHaveLength(1); + }); + + it("marks the first slide as the one on screen", () => { + const html = render({}); + expect(html).toMatch(/class="slide slide-on-text is-cur"/); + expect(html).toContain('data-current="0"'); + }); + + it("shows the frosted menu only on the person's own posts", () => { + const html = render({}); + expect(html.match(/slide-menu-btn/g)).toHaveLength(1); + expect(html).toContain('aria-label="More actions"'); + expect(render({ feedUserId: "someone-else" })).not.toContain("slide-menu-btn"); + }); + + it("ends with a loading slide while another page can load", () => { + expect(render({ hasMore: true })).toContain('data-testid="feed-slide-load"'); + expect(render({ loadingMore: true })).toContain('data-testid="feed-slide-load"'); + expect(render({})).not.toContain("feed-slide-load"); + const failed = render({ hasMore: true, loadMoreFailed: true }); + expect(failed).not.toContain("feed-slide-load"); + expect(failed).toContain("Try again"); + }); + + it("shows the loading slide on first load", () => { + expect(render({ status: "loading", posts: [] })).toContain('data-testid="feed-slide-load"'); + }); + + it("picks the slide that is mostly on screen", () => { + expect(pickCurrent(0, [])).toBe(0); + expect(pickCurrent(0, [1])).toBe(1); + expect(pickCurrent(2, [1, 2])).toBe(2); + expect(pickCurrent(4, [1, 2])).toBe(1); + }); +}); + +describe("mobile stylesheet", () => { + const css = readFileSync("src/styles/mobile.css", "utf8"); + it("snaps one slide per screen", () => { + expect(css).toMatch(/\.reel \{[^}]*scroll-snap-type: y mandatory/); + expect(css).toMatch(/\.slide \{[^}]*height: 100%;[^}]*scroll-snap-stop: always/); + }); + it("uses a 31px serif quote with a crimson mark, and eases media from 104%", () => { + expect(css).toMatch(/\.slide-quote \{[^}]*font-size: 31px/); + expect(css).toMatch(/\.slide-quote::before \{[^}]*color: var\(--accent\)/); + expect(css).toMatch(/transform: scale\(1\.04\)/); + expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/); + }); + it("uses tokens only", () => { + expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/); + }); +}); + +describe("one step per gesture", () => { + it("moves one slide in the gesture's direction and stays inside the reel", async () => { + const { stepTarget } = await import("@/components/MobileFeed"); + expect(stepTarget(0, 120, 5)).toBe(1); + expect(stepTarget(3, -4, 5)).toBe(2); + expect(stepTarget(4, 300, 5)).toBe(4); + expect(stepTarget(0, -50, 5)).toBe(0); + expect(stepTarget(2, 0, 5)).toBe(2); + }); +});