From bf5e0ed4750b605211f317749a9231e6462c460f Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:21:28 -0600 Subject: [PATCH 1/9] xrdwb17xu: Add the full-screen snap feed with media and quote slides on phones --- src/app/globals.css | 9 + src/app/layout.tsx | 1 + src/components/AppShell.tsx | 23 ++- src/components/Feed.tsx | 7 +- src/components/FeedView.tsx | 11 ++ src/components/MobileFeed.tsx | 281 +++++++++++++++++++++++++++++++ src/components/PostCard.tsx | 120 +++++++------ src/components/layout-context.ts | 22 +++ src/dev/FeedPreview.tsx | 40 ++++- src/styles/layout.css | 10 +- src/styles/mobile.css | 231 +++++++++++++++++++++++++ tests/mobile-feed.test.ts | 121 +++++++++++++ 12 files changed, 815 insertions(+), 61 deletions(-) create mode 100644 src/components/MobileFeed.tsx create mode 100644 src/components/layout-context.ts create mode 100644 src/styles/mobile.css create mode 100644 tests/mobile-feed.test.ts 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); + }); +}); From adcd0cfd96e3eef00c41ba63652a7c35e252b0ae Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:24:45 -0600 Subject: [PATCH 2/9] xrdwb17xu: Hide native video controls until play and move the author aside while a slide video plays --- src/components/MobileFeed.tsx | 16 +++++++++++++--- src/components/VideoPlayer.tsx | 6 ++++-- src/styles/mobile.css | 11 +++++++++++ tests/mobile-feed.test.ts | 10 ++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 0e9661c..344f88d 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -54,7 +54,7 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) { return (
- +
{started ? null : ( <> @@ -85,9 +85,19 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) { 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" : ""}`; + // While a video plays, the author and text step aside so the native controls stay clear. + const [playing, setPlaying] = useState(false); + const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`; return ( -
+
setPlaying(true)} + onPauseCapture={() => setPlaying(false)} + onEndedCapture={() => setPlaying(false)} + > {media ? ( <> diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 8c2c73e..ff2220a 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -8,6 +8,8 @@ export interface VideoPlayerProps { hlsUrl?: string | null; poster: string | null; label: string; + /** Native controls; off until the caller's own play button starts the video. Default on. */ + controls?: boolean; } const UNPLAYABLE = "Video can't be played here."; @@ -18,7 +20,7 @@ const UNPLAYABLE = "Video can't be played here."; * unsupported), else thumbnail only. Never * autoplays. Any failure falls back to the thumbnail with a short note. */ -export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) { +export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }: VideoPlayerProps) { const ref = useRef(null); const [failed, setFailed] = useState(false); @@ -77,7 +79,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)
); } diff --git a/src/styles/mobile.css b/src/styles/mobile.css index 0584934..ff32f9b 100644 --- a/src/styles/mobile.css +++ b/src/styles/mobile.css @@ -73,7 +73,7 @@ position: absolute; left: 0; right: 0; - bottom: 84px; + bottom: 90px; z-index: 4; display: flex; justify-content: center; @@ -320,12 +320,60 @@ } .reel-pill > * { pointer-events: auto; } +/* Bottom dock (spec "Bottom dock"): a frosted bar 16px above the bottom. Like and + comments act on the post on screen. Disabled buttons stay fully opaque and use + a solid muted ink instead of fading. */ +.dock { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + z-index: 30; + display: flex; + align-items: center; + gap: 4px; + padding: 6px; + border-radius: 22px; + background: var(--glass); + -webkit-backdrop-filter: blur(24px) saturate(1.8); + backdrop-filter: blur(24px) saturate(1.8); + box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line); +} +.dk { + appearance: none; + flex: 1; + min-width: 44px; + height: 52px; + padding: 0; + border: 0; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: transparent; + color: var(--ink); + font-size: 14px; + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: background-color .25s, color .25s; +} +.dk svg { width: 22px; height: 22px; flex: none; } +@media (hover: hover) { .dk:hover:not(:disabled) { background: var(--tile-3); } } +.dk:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.dk.dk-like-on { color: var(--accent); } +.dk-beat svg { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); } +.dk:disabled { opacity: 1; color: var(--ink-3); cursor: default; } +.btn-spark.btn-spark-fab.dk-plus { flex: 0 0 64px; width: 64px; height: 52px; } + @media (prefers-reduced-motion: reduce) { .is-cur .slide-meta, .is-cur .slide-quote-w, .slide-skel i, .slide-burst, .slide-hint svg, - .reel-pill { animation: none !important; } + .reel-pill, + .dk-beat svg { animation: none !important; } .slide-art { transition: none !important; } } diff --git a/tests/mobile-dock.test.ts b/tests/mobile-dock.test.ts new file mode 100644 index 0000000..146a760 --- /dev/null +++ b/tests/mobile-dock.test.ts @@ -0,0 +1,80 @@ +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 { MobileDock, dockState } from "@/components/MobileDock"; +import { CompactLayoutContext } from "@/components/layout-context"; +import { toCardPosts } from "@/lib/feed-model"; + +const item = (id: string, likes: number, comments: number, liked = false) => ({ + type: "post", + publishedAt: "2026-10-19T11:00:00Z", + likesCount: likes, + commentsCount: comments, + isLiked: liked, + post: { id, content: `text ${id}`, author: { id: "a", displayName: `Author ${id}` }, mediaItems: [] }, +}); +const POSTS = toCardPosts([item("p1", 12, 3), item("p2", 40, 1, true)]); + +function feed(over: Partial, compact = true) { + const props: FeedViewProps = { + posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false, + newCount: 0, onRetry: () => {}, onShowNew: () => {}, onToggleLike: () => {}, ...over, + }; + return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props))); +} + +describe("dock state", () => { + it("shows the counts and liked state of the post on screen", () => { + const s = dockState({ index: 1, kind: "post", post: POSTS[1] }, { canLike: true }); + expect(s.likes).toBe(POSTS[1].likesCount); + expect(s.comments).toBe(POSTS[1].commentsCount); + expect(s.liked).toBe(POSTS[1].isLiked); + expect(s.likeDisabled).toBe(false); + expect(s.commentsDisabled).toBe(false); + }); + it("disables like and comments on the featured and loading slides", () => { + for (const kind of ["featured", "load"] as const) { + const s = dockState({ index: 2, kind, post: null }); + expect(s.likeDisabled).toBe(true); + expect(s.commentsDisabled).toBe(true); + expect(s.likes).toBeNull(); + } + }); + it("disables like while the like call is in flight", () => { + expect(dockState({ index: 0, kind: "post", post: POSTS[0] }, { canLike: true, likeBusy: true }).likeDisabled).toBe(true); + }); +}); + +describe("dock markup", () => { + it("renders like with aria-pressed, comments, the sparkle new-post button and More", () => { + const out = renderToStaticMarkup(createElement(MobileDock, { slide: { index: 1, kind: "post", post: POSTS[1] }, onToggleLike: () => {} })); + expect(out).toContain('aria-label="Post actions"'); + expect(out).toMatch(/class="dk dk-like dk-like-on"[^>]*aria-pressed="true"/); + expect(out).toContain(`>${POSTS[1].likesCount}`); + expect(out).toContain(`Open comments, ${POSTS[1].commentsCount} comment`); + expect(out).toContain("btn-spark btn-spark-fab dk-plus"); + expect(out).not.toContain("btn-spark-fold"); + expect(out).toContain('aria-label="View more"'); + }); + it("is part of the phone feed and follows the first slide", () => { + const out = feed({}); + expect(out).toContain('data-testid="feed-dock"'); + expect(out).toContain(`data-testid="dock-likes">${POSTS[0].likesCount}<`); + }); + it("is absent on desktop", () => { + expect(feed({}, false)).not.toContain("feed-dock"); + }); +}); + +describe("dock stylesheet", () => { + const css = readFileSync("src/styles/mobile.css", "utf8"); + it("floats 16px above the bottom on frosted glass", () => { + expect(css).toMatch(/\.dock \{[^}]*bottom: calc\(16px[^}]*backdrop-filter: blur/); + }); + it("keeps touch targets at least 44px and disabled buttons fully opaque", () => { + expect(css).toMatch(/\.dk \{[^}]*min-width: 44px;[^}]*height: 52px/); + expect(css).toMatch(/\.dk:disabled \{ opacity: 1;/); + }); +}); From f4f3ae28fce018367ad527bd043d25b53fa8f17b Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:42:32 -0600 Subject: [PATCH 6/9] 3syisgbyq: Add a reusable bottom sheet with the comments and composer sheets on the phone feed --- src/app/layout.tsx | 1 + src/components/BottomSheet.tsx | 90 ++++++++++++++++++++++++++++++++ src/components/MobileFeed.tsx | 36 +++++++++++-- src/styles/sheet.css | 93 ++++++++++++++++++++++++++++++++++ tests/bottom-sheet.test.ts | 38 ++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/components/BottomSheet.tsx create mode 100644 src/styles/sheet.css create mode 100644 tests/bottom-sheet.test.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea58f0d..0842ede 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,6 +15,7 @@ import "../styles/feed.css"; import "../styles/trending.css"; import "../styles/search.css"; import "../styles/mobile.css"; +import "../styles/sheet.css"; import "../styles/legacy.css"; const manrope = Manrope({ diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx new file mode 100644 index 0000000..4ac05a9 --- /dev/null +++ b/src/components/BottomSheet.tsx @@ -0,0 +1,90 @@ +"use client"; + +// Bottom sheet (spec "Sheets"): slides up over a dimmed, blurred backdrop with a +// grab handle, a serif title and a close button. Reused by the comments, +// composer and Discover sheets. Styles live in src/styles/sheet.css. +import { useEffect, useId, useRef, type ReactNode } from "react"; + +const FOCUSABLE = 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'; + +export interface BottomSheetProps { + title: string; + onClose: () => void; + children: ReactNode; + /** Extra class on the sheet, for per-sheet sizing. */ + className?: string; +} + +export function BottomSheet({ title, onClose, children, className }: BottomSheetProps) { + const sheet = useRef(null); + const titleId = useId(); + const closeRef = useRef(onClose); + closeRef.current = onClose; + + useEffect(() => { + const el = sheet.current; + if (!el) return; + const opener = document.activeElement as HTMLElement | null; + // Focus moves into the sheet: its first field, else the sheet itself. + const first = el.querySelector("input, textarea") ?? el.querySelector(FOCUSABLE); + (first ?? el).focus({ preventScroll: true }); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + // An open emoji popover closes first; it handles Escape itself. + if (el?.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]')) return; + e.preventDefault(); + closeRef.current(); + return; + } + if (e.key !== "Tab" || !el) return; + const items = [...el.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetParent !== null || n === document.activeElement); + if (!items.length) return; + const a = items[0]; + const z = items[items.length - 1]; + if (e.shiftKey && (document.activeElement === a || document.activeElement === el)) { + e.preventDefault(); + z.focus(); + } else if (!e.shiftKey && document.activeElement === z) { + e.preventDefault(); + a.focus(); + } + } + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + // Focus returns to the opener (when it is still on the page). + if (opener && opener.isConnected) opener.focus({ preventScroll: true }); + }; + }, []); + + return ( + <> + +
+
{children}
+ + + ); +} diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index f6c581c..f68710f 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, type MouseEvent, type Ref } from "react"; +import { useCallback, useEffect, useRef, useState, type MouseEvent, 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"; @@ -8,6 +8,9 @@ import { PlayIcon, PostMenu } from "./PostCard"; import { VideoPlayer } from "./VideoPlayer"; import { FeaturedCarousel } from "./FeaturedCarousel"; import { MobileDock } from "./MobileDock"; +import { BottomSheet } from "./BottomSheet"; +import { CommentsSection } from "./CommentsSection"; +import { Composer } from "./Composer"; import type { DiscoverExpert } from "@/lib/discover"; import type { FeedViewProps } from "./FeedView"; @@ -278,6 +281,17 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo export function MobileFeed(p: FeedViewProps) { const reel = useRef(null); const [current, setCurrent] = useState(0); + // The open sheet: the comments of one post, the composer, or none. + const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | null>(null); + const closeSheet = useCallback(() => setSheet(null), []); + // A successful post closes the composer: publishing ends with no error and an emptied draft. + const wasBusy = useRef(false); + const composerBusy = !!p.composer?.busy; + const composerClear = !!p.composer && !p.composer.error && !p.composer.value && !p.composer.file; + useEffect(() => { + if (wasBusy.current && !composerBusy && composerClear) setSheet((s) => (s?.kind === "composer" ? null : s)); + wasBusy.current = composerBusy; + }, [composerBusy, composerClear]); const onCurrentChange = p.onCurrentChange; const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore)); const experts = p.featuredExperts ?? []; @@ -421,11 +435,27 @@ export function MobileFeed(p: FeedViewProps) { slide={{ index, kind, post: currentPost }} onToggleLike={p.onToggleLike} likeBusy={currentPost ? p.likeBusy?.has(currentPost.key) : false} - onOpenComments={p.onOpenComments} - onOpenComposer={p.onOpenComposer} + onOpenComments={(postId) => { + setSheet({ kind: "comments", postId }); + p.onOpenComments?.(postId); + }} + onOpenComposer={() => { + setSheet({ kind: "composer" }); + p.onOpenComposer?.(); + }} onOpenMore={p.onOpenMore} /> ) : null} + {sheet?.kind === "comments" ? ( + + + + ) : null} + {sheet?.kind === "composer" && p.composer ? ( + + + + ) : null} ); } diff --git a/src/styles/sheet.css b/src/styles/sheet.css new file mode 100644 index 0000000..bde550b --- /dev/null +++ b/src/styles/sheet.css @@ -0,0 +1,93 @@ +/* + * Bottom sheets (spec "Sheets"). Ported from the mockup's .psheet, .grab, + * .psheet-h and .ph-bg rules, plus the `.mx.sheet .cmp` composer rules. Tokens + * only. The sheet sits inside the phone feed (.reel-feed), over the dock. + */ +@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } } +@keyframes sheet-fade { from { opacity: 0; } to { opacity: 1; } } + +.sheet-bg { + position: absolute; + inset: 0; + z-index: 55; + appearance: none; + border: 0; + padding: 0; + cursor: pointer; + background: color-mix(in srgb, var(--bg), transparent 50%); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + animation: sheet-fade .3s both; +} +.sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 60; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 78%; + padding: 10px 14px calc(24px + env(safe-area-inset-bottom, 0px)); + background: var(--bg); + color: var(--ink); + border-radius: 26px 26px 0 0; + box-shadow: 0 -20px 50px -10px color-mix(in srgb, var(--neu-d), transparent 45%), inset 0 1px 0 var(--line); + animation: sheet-up .45s cubic-bezier(.2,.9,.25,1) both; + outline: none; +} +.sheet-grab { + flex: none; + width: 40px; + height: 5px; + margin: 0 auto 4px; + border-radius: 3px; + background: var(--line-2); +} +.sheet-h { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.sheet-h h2 { margin: 0; font-size: 25px; line-height: 1.2; } +.sheet-x { + appearance: none; + flex: none; + width: 44px; + height: 44px; + margin: 0; + padding: 0; + border: 0; + border-radius: var(--r-ctl); + display: grid; + place-items: center; + background: transparent; + color: var(--ink-2); + cursor: pointer; + transition: background-color .2s, color .2s; +} +.sheet-x svg { width: 20px; height: 20px; } +@media (hover: hover) { .sheet-x:hover { background: var(--tile-2); color: var(--ink); } } +.sheet-x:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.sheet-body { min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; } + +/* Comments sheet: the shared comments panel without its inline-card chrome. */ +.sheet-comments .post-cm { margin: 0; padding: 0; border-top: 0; animation: none; flex: 1; min-height: 0; } +.sheet-comments .post-cm-list { overflow-y: auto; min-height: 60px; } +.sheet-comments .post-cm-form { flex: none; } +.sheet-comments .post-cm-form input { font-size: 16px; } + +/* Composer sheet: the desktop composer, borderless, with a 150px text area. */ +.sheet-composer { max-height: 92%; } +.sheet-composer .sheet-body { overflow: visible; } +.sheet-composer .composer, +.sheet-composer .composer.composer-open { margin: 0; padding: 0; background: transparent; box-shadow: none; } +.sheet-composer .composer textarea, +.sheet-composer .composer.composer-open textarea { height: 150px; font-size: 16px; } +.sheet-composer .composer-bar { flex-wrap: wrap; } +.sheet-composer .composer-kbd { display: none; } +/* The popover anchors to the whole composer (not the emoji button) so it fits a phone: full width, just above the toolbar. */ +.sheet-composer .composer-emoji-wrap { position: static; } +.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; } +.sheet-composer .composer-att.composer-att-indent { margin-left: 0; } + +@media (prefers-reduced-motion: reduce) { + .sheet, .sheet-bg { animation: none !important; } +} diff --git a/tests/bottom-sheet.test.ts b/tests/bottom-sheet.test.ts new file mode 100644 index 0000000..7261808 --- /dev/null +++ b/tests/bottom-sheet.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { createElement, type FC } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { BottomSheet } from "@/components/BottomSheet"; + +const css = readFileSync("src/styles/sheet.css", "utf8"); +const feed = readFileSync("src/components/MobileFeed.tsx", "utf8"); + +describe("BottomSheet", () => { + const html = renderToStaticMarkup(createElement(BottomSheet as FC<{ title: string; onClose: () => void }>, { title: "New post", onClose: () => {} }, createElement("p", null, "body"))); + it("is a modal dialog named by its serif title, with a grab handle, close buttons and a backdrop", () => { + expect(html).toContain('role="dialog"'); + expect(html).toContain('aria-modal="true"'); + expect(html).toMatch(/aria-labelledby="([^"]+)"[\s\S]*

]*type-title[^>]*>New post<\/h2>/); + expect(html).toContain("sheet-grab"); + expect(html).toContain('class="sheet-bg"'); + expect(html.match(/aria-label="Close"/g)?.length).toBe(2); + expect(html).toContain("

body

"); + }); + it("slides up over 450ms and turns the motion off under reduced motion", () => { + expect(css).toMatch(/animation: sheet-up \.45s/); + expect(css).toMatch(/prefers-reduced-motion: reduce[\s\S]*animation: none/); + expect(css).toMatch(/backdrop-filter: blur\(3px\)/); + }); + it("gives the composer sheet a 150px text area", () => { + expect(css).toMatch(/\.sheet-composer \.composer textarea[\s\S]*height: 150px/); + }); +}); + +describe("phone feed sheets", () => { + it("opens the shared comments section and composer from the dock", () => { + expect(feed).toContain('"); + }); +}); From 695ebe109548dd00a17f2caf67a90198bfc0d204 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:44:38 -0600 Subject: [PATCH 7/9] 3syisgbyq: Keep sheet controls clear of the home indicator with a safe-area bottom padding and viewport-fit cover --- src/app/layout.tsx | 5 ++++- src/styles/sheet.css | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0842ede..1d69a50 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import { Cormorant_Garamond, Manrope } from "next/font/google"; import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme"; @@ -33,6 +33,9 @@ const cormorant = Cormorant_Garamond({ display: "swap", }); +// viewport-fit=cover makes env(safe-area-inset-*) report the iPhone home indicator. +export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" }; + export const metadata: Metadata = { title: "Minnect feed browser", description: "Sign in with your Minnect account to read and write the home feed.", diff --git a/src/styles/sheet.css b/src/styles/sheet.css index bde550b..f1f5e16 100644 --- a/src/styles/sheet.css +++ b/src/styles/sheet.css @@ -29,7 +29,7 @@ flex-direction: column; gap: 10px; max-height: 78%; - padding: 10px 14px calc(24px + env(safe-area-inset-bottom, 0px)); + padding: 10px 14px max(20px, calc(env(safe-area-inset-bottom, 0px) + 12px)); background: var(--bg); color: var(--ink); border-radius: 26px 26px 0 0; From cfdca1f270c8402367ec665e80a274ed9dd4c23d Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:49:11 -0600 Subject: [PATCH 8/9] ai1ywm5xi: Add the Discover sheet with featured, trending, find experts panels and the appearance switch --- src/components/App.tsx | 2 +- src/components/AppShell.tsx | 4 +- src/components/Buttons.tsx | 7 +- src/components/DiscoverSheet.tsx | 148 +++++++++++++++++++++++++++++++ src/components/Feed.tsx | 10 ++- src/components/FeedView.tsx | 6 +- src/components/MobileFeed.tsx | 16 +++- src/dev/FeedPreview.tsx | 12 +++ src/styles/sheet.css | 62 +++++++++++++ tests/discover-sheet.test.ts | 50 +++++++++++ 10 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 src/components/DiscoverSheet.tsx create mode 100644 tests/discover-sheet.test.ts diff --git a/src/components/App.tsx b/src/components/App.tsx index 0084213..c1d9b95 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -49,7 +49,7 @@ function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: () } - feed={} + feed={} />} discover={} /> ); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 877b39a..2ad627d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -96,11 +96,11 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
- {search} + {compact ? null : search}
{feed}
diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 41ac174..6ec6719 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -147,14 +147,17 @@ export interface DepthButtonProps extends NativeButton { label: string; sublabel?: string; icon?: ReactNode; + /** Raw leading content in place of the icon tile, for example a stack of avatars. */ + leading?: ReactNode; /** Trailing content, for example a chevron or the current theme name. */ trailing?: ReactNode; } -export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) { +export function DepthButton({ label, sublabel, icon, leading, trailing, className, type = "button", ...rest }: DepthButtonProps) { return ( + ))} + + ); +} + +export interface DiscoverSheetProps { + experts: DiscoverExpert[]; + onOpen: (panel: DiscoverPanel) => void; + onClose: () => void; +} + +export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) { + return ( + + {experts.length > 0 ? ( + onOpen("featured")} + leading={ + + } + trailing={} + /> + ) : null} + } trailing={} onClick={() => onOpen("trending")} /> + } trailing={} onClick={() => onOpen("search")} /> + Appearance + + + ); +} + +export interface DiscoverPanelSheetProps { + panel: DiscoverPanel; + experts: DiscoverExpert[]; + trending: DiscoverMinnect[]; + search?: ReactNode; + onClose: () => void; +} + +const TITLES: Record = { + featured: "Featured experts", + trending: "Trending minnects", + search: "Find experts", +}; + +/** One Discover panel as an 80% height sheet with a close button; reuses the desktop components. */ +export function DiscoverPanelSheet({ panel, experts, trending, search, onClose }: DiscoverPanelSheetProps) { + return ( + + {panel === "featured" ? : null} + {panel === "trending" ? : null} + {panel === "search" ? search : null} + + ); +} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index e311479..60f6211 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -6,7 +6,8 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model"; import { PostError, publishAndConfirm } from "@/lib/post-client"; import { DeleteError, sendDelete } from "@/lib/delete-client"; -import type { DiscoverExpert } from "@/lib/discover"; +import type { ReactNode } from "react"; +import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover"; import type { ComposerProps } from "./Composer"; import { FeedView, type FeedStatus } from "./FeedView"; import { useCompactLayout } from "./layout-context"; @@ -18,6 +19,8 @@ export function Feed({ feedUserId, person, featuredExperts, + trendingMinnects, + searchPanel, }: { onSignedOut: () => void; feedUserId?: string | null; @@ -25,6 +28,9 @@ export function Feed({ person?: ComposerProps["person"]; /** Shown as a slide after the second post on phones. */ featuredExperts?: DiscoverExpert[]; + /** Phone Discover sheet: trending minnects and the Find experts panel. */ + trendingMinnects?: DiscoverMinnect[]; + searchPanel?: ReactNode; }) { const [posts, setPosts] = useState([]); const [status, setStatus] = useState("loading"); @@ -285,6 +291,8 @@ export function Feed({ onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }} featuredExperts={featuredExperts} + trendingMinnects={trendingMinnects} + searchPanel={searchPanel} hasMore={!!nextCursor} sentinelRef={sentinel} listRef={list} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 854d069..777951d 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -2,7 +2,7 @@ import type { ReactNode, Ref } from "react"; import { canDelete, type CardPost } from "@/lib/feed-model"; -import type { DiscoverExpert } from "@/lib/discover"; +import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover"; import { Composer, type ComposerProps } from "./Composer"; import { GrowButton, IconButton } from "./Buttons"; import { PostCard } from "./PostCard"; @@ -42,6 +42,10 @@ export interface FeedViewProps { hasMore?: boolean; /** Phone layout only: experts for the featured slide after the second post. */ featuredExperts?: DiscoverExpert[]; + /** Phone layout only: trending minnects for the Discover sheet. */ + trendingMinnects?: DiscoverMinnect[]; + /** Phone layout only: the Find experts panel shown in the Discover sheet. */ + searchPanel?: ReactNode; /** Phone layout only: the slide on screen changed (for the dock). */ onCurrentChange?: (slide: CurrentSlide) => void; /** Phone layout only: the dock's comments button, for the post on screen. */ diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index f68710f..86b1244 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -11,6 +11,7 @@ import { MobileDock } from "./MobileDock"; import { BottomSheet } from "./BottomSheet"; import { CommentsSection } from "./CommentsSection"; import { Composer } from "./Composer"; +import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet"; import type { DiscoverExpert } from "@/lib/discover"; import type { FeedViewProps } from "./FeedView"; @@ -281,8 +282,8 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo export function MobileFeed(p: FeedViewProps) { const reel = useRef(null); const [current, setCurrent] = useState(0); - // The open sheet: the comments of one post, the composer, or none. - const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | null>(null); + // The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none. + const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null); const closeSheet = useCallback(() => setSheet(null), []); // A successful post closes the composer: publishing ends with no error and an emptied draft. const wasBusy = useRef(false); @@ -443,7 +444,10 @@ export function MobileFeed(p: FeedViewProps) { setSheet({ kind: "composer" }); p.onOpenComposer?.(); }} - onOpenMore={p.onOpenMore} + onOpenMore={() => { + setSheet({ kind: "more" }); + p.onOpenMore?.(); + }} /> ) : null} {sheet?.kind === "comments" ? ( @@ -456,6 +460,12 @@ export function MobileFeed(p: FeedViewProps) {
) : null} + {sheet?.kind === "more" ? ( + setSheet({ kind: "panel", panel })} onClose={closeSheet} /> + ) : null} + {sheet?.kind === "panel" ? ( + + ) : null} ); } diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx index 364de9a..2390611 100644 --- a/src/dev/FeedPreview.tsx +++ b/src/dev/FeedPreview.tsx @@ -126,6 +126,18 @@ export function FeedPreview() { loadingMore={loadingMore || paging} hasMore={hasMore} featuredExperts={FIXTURE_DISCOVER.featuredExperts} + trendingMinnects={FIXTURE_DISCOVER.trendingMinnects} + searchPanel={ + + } sentinelRef={sentinel} listRef={listRef} loadMoreFailed={false} diff --git a/src/styles/sheet.css b/src/styles/sheet.css index f1f5e16..d6d6d27 100644 --- a/src/styles/sheet.css +++ b/src/styles/sheet.css @@ -88,6 +88,68 @@ .sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; } .sheet-composer .composer-att.composer-att-indent { margin-left: 0; } +/* Discover sheet (More): soft depth rows, then the Appearance switch. */ +.sheet-discover { gap: 14px; } +.sheet-discover .sheet-body { gap: 16px; padding: 4px 4px 4px; overflow-y: auto; } +.sheet-discover .btn-depth { position: relative; overflow: hidden; isolation: isolate; min-height: 72px; } +.sheet-discover .btn-depth-chev { width: 18px; height: 18px; flex: none; color: var(--ink-3); transition: color .3s; } +.sheet-discover .btn-depth:hover .btn-depth-chev, +.sheet-discover .btn-depth:focus-visible .btn-depth-chev { color: var(--on-accent); } +.depth-stack { display: flex; flex: none; } +.depth-stack .hex-avatar { margin-left: -10px; } +.depth-stack .hex-avatar:first-child { margin-left: 0; } +.sheet-sub { margin: 4px 0 -6px 4px; color: var(--ink-3); } +.seg-depth { + display: flex; + gap: 4px; + padding: 4px; + border-radius: var(--r-card); + background: var(--bg); + box-shadow: inset 4px 4px 10px var(--neu-d), inset -4px -4px 10px var(--neu-l); +} +.seg-depth button { + appearance: none; + border: 0; + cursor: pointer; + flex: 1; + height: 44px; + border-radius: var(--r-ctl); + background: transparent; + color: var(--ink-3); + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background-color .25s, color .25s, box-shadow .25s; +} +.seg-depth button svg { width: 18px; height: 18px; } +.seg-depth button.on { + background: var(--bg); + color: var(--ink); + box-shadow: 4px 4px 10px var(--neu-d), -4px -4px 10px var(--neu-l); +} +.seg-depth button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } + +/* Panel sheets: 80% height, the desktop panels without their tile chrome or repeated heading. */ +.sheet-panel { height: 80%; max-height: 80%; } +.sheet-panel .sheet-body { flex: 1; overflow: hidden; } +.sheet-panel .sheet-body > .tile { + flex: 1; + min-height: 0; + margin: 0; + padding: 0; + background: transparent; + box-shadow: none; + border-radius: 0; +} +.sheet-panel .sheet-body > .tile > .panel-head { display: none; } +.sheet-panel .search-results, +.sheet-panel .trend-list { flex: 1 1 0; min-height: 0; overflow-y: auto; } +.sheet-panel .search-field input { font-size: 16px; } +.sheet-panel-featured .sheet-body { overflow-y: auto; align-items: center; justify-content: center; } + @media (prefers-reduced-motion: reduce) { .sheet, .sheet-bg { animation: none !important; } } diff --git a/tests/discover-sheet.test.ts b/tests/discover-sheet.test.ts new file mode 100644 index 0000000..fbab132 --- /dev/null +++ b/tests/discover-sheet.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { DiscoverPanelSheet, DiscoverSheet, featuredSubtitle, PANEL_CAROUSEL_SIZE } from "@/components/DiscoverSheet"; +import { FIXTURE_DISCOVER } from "@/dev/fixtures"; + +const experts = FIXTURE_DISCOVER.featuredExperts; +const html = (el: ReturnType) => renderToStaticMarkup(el); + +describe("featuredSubtitle", () => { + it("names two experts and counts the rest", () => { + const list = [{ displayName: "A" }, { displayName: "B" }, { displayName: "C" }, { displayName: "D" }]; + expect(featuredSubtitle(list)).toBe("A, B and 2 more"); + expect(featuredSubtitle(list.slice(0, 3))).toBe("A, B and 1 more"); + }); + it("reads plainly for short lists", () => { + expect(featuredSubtitle([])).toBe(""); + expect(featuredSubtitle([{ displayName: "A" }])).toBe("A"); + expect(featuredSubtitle([{ displayName: "A" }, { displayName: "B" }])).toBe("A and B"); + }); +}); + +describe("Discover sheet", () => { + const out = html(createElement(DiscoverSheet, { experts, onOpen: () => {}, onClose: () => {} })); + it("has three depth rows and the appearance switch", () => { + expect(out).toContain("Discover"); + expect((out.match(/btn-depth"/g) ?? []).length).toBe(3); + expect(out).toContain("Featured experts"); + expect(out).toContain("Trending minnects"); + expect(out).toContain("Find experts"); + expect(out).toContain('aria-label="Appearance"'); + expect(out).toContain("Dark"); + expect(out).toContain("Light"); + expect(out).toContain(featuredSubtitle(experts)); + }); +}); + +describe("Discover panel sheets", () => { + it("shows the featured carousel at 150px", () => { + expect(PANEL_CAROUSEL_SIZE).toBe(150); + const out = html(createElement(DiscoverPanelSheet, { panel: "featured", experts, trending: [], onClose: () => {} })); + expect(out).toContain("--cfw:150px"); + expect(out).toContain('aria-label="Close"'); + }); + it("is 80% high", () => { + const css = readFileSync("src/styles/sheet.css", "utf8"); + expect(css).toMatch(/\.sheet-panel\s*\{[^}]*height:\s*80%/); + }); +}); From a757c960ebe1e63a0ae55fc68f82de04bb2340de Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:53:40 -0600 Subject: [PATCH 9/9] vhg6j0bp2: Add frosted top bar and progress rail to the mobile feed --- src/components/MobileFeed.tsx | 39 +++++++++++++++++++++++- src/styles/mobile.css | 57 +++++++++++++++++++++++++++++++++-- tests/mobile-feed.test.ts | 26 +++++++++++++++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 86b1244..91e55db 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -7,6 +7,8 @@ import { HexAvatar } from "./HexAvatar"; import { PlayIcon, PostMenu } from "./PostCard"; import { VideoPlayer } from "./VideoPlayer"; import { FeaturedCarousel } from "./FeaturedCarousel"; +import { Logo } from "./Logo"; +import { useTheme } from "@/lib/use-theme"; import { MobileDock } from "./MobileDock"; import { BottomSheet } from "./BottomSheet"; import { CommentsSection } from "./CommentsSection"; @@ -220,6 +222,35 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number; ); } +/** Height of the progress rail, as a percent: the slide on screen out of the loaded slides. */ +export function railPercent(index: number, count: number): number { + if (count <= 0) return 0; + return Math.round((Math.min(Math.max(index, 0), count - 1) + 1) / count * 100); +} + +const topIcon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const; + +/** Frosted pills over the feed (spec "Top bar"): logo and wordmark left, theme toggle right. */ +function TopBar() { + const { theme, toggleTheme } = useTheme(); + const next = theme === "dark" ? "light" : "dark"; + return ( +
+
+ + MINNECT +
+ +
+ ); +} + function ChevronDown() { return (
) : null} + +
* { pointer-events: auto; } +.ph-glass { + 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); + color: var(--ink); + transition: background .3s var(--ease), color .3s var(--ease); +} +.reel-feed[data-on-media="true"] .ph-glass { + background: var(--media-glass); + color: var(--on-media); + box-shadow: inset 0 0 0 1px var(--on-media-line); +} +.ph-brand { display: flex; align-items: center; gap: 8px; height: 42px; padding: 0 14px 0 10px; border-radius: 14px; } +.ph-brand .type-wordmark { font-size: 13px; } +.ph-theme { appearance: none; border: 0; margin: 0; display: grid; place-items: center; width: 42px; height: 42px; padding: 0; border-radius: 14px; cursor: pointer; } +.ph-theme svg { width: 20px; height: 20px; } +.ph-theme:active { transform: scale(.94); } + +/* Progress rail: position in the loaded feed, on the right edge. */ +.rail-v { + position: absolute; + right: 5px; + top: 50%; + height: 110px; + margin-top: -55px; + width: 3px; + border-radius: 2px; + background: var(--line-2); + z-index: 15; + overflow: hidden; + pointer-events: none; +} +.rail-v i { display: block; width: 100%; background: var(--accent); border-radius: 2px; transition: height .5s var(--ease); } +@media (prefers-reduced-motion: reduce) { .rail-v i { transition: none; } } diff --git a/tests/mobile-feed.test.ts b/tests/mobile-feed.test.ts index f007e86..618ffae 100644 --- a/tests/mobile-feed.test.ts +++ b/tests/mobile-feed.test.ts @@ -3,7 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { readFileSync } from "node:fs"; import { FeedView, type FeedViewProps } from "@/components/FeedView"; -import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex } from "@/components/MobileFeed"; +import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex, railPercent } from "@/components/MobileFeed"; import { FIXTURE_DISCOVER } from "@/dev/fixtures"; import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context"; import { toCardPosts } from "@/lib/feed-model"; @@ -171,3 +171,27 @@ describe("featured experts slide", () => { expect(render({})).not.toContain("feed-slide-featured"); }); }); + +describe("top bar and progress rail", () => { + it("renders the brand pill, the theme toggle and the rail", () => { + const html = render({}); + expect(html).toContain('data-testid="top-bar"'); + expect(html).toContain("MINNECT"); + expect(html).toMatch(/data-testid="top-theme"[^>]*aria-label="Switch to (light|dark) theme"/); + expect(html).toContain('data-testid="feed-rail"'); + expect(html).toContain('data-on-media="false"'); + }); + + it("sizes the rail by the slide on screen out of the loaded slides", () => { + expect(railPercent(0, 4)).toBe(25); + expect(railPercent(2, 4)).toBe(75); + expect(railPercent(9, 4)).toBe(100); + expect(railPercent(0, 0)).toBe(0); + }); + + it("keeps the own-post menu below the bar and the safe area", () => { + const css = readFileSync("src/styles/mobile.css", "utf8"); + expect(css).toMatch(/\.slide-menu \{[^}]*top: calc\(70px \+ env\(safe-area-inset-top/); + expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/); + }); +});