diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index f3dfdba..854d069 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -40,10 +40,16 @@ export interface FeedViewProps { 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). */ /** Phone layout only: experts for the featured slide after the second post. */ featuredExperts?: DiscoverExpert[]; + /** 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. */ + onOpenComments?: (postId: string) => void; + /** Phone layout only: the dock's new-post button. */ + onOpenComposer?: () => void; + /** Phone layout only: the dock's More button (the Discover sheet). */ + onOpenMore?: () => void; } function Spinner() { diff --git a/src/components/MobileDock.tsx b/src/components/MobileDock.tsx new file mode 100644 index 0000000..481cec1 --- /dev/null +++ b/src/components/MobileDock.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { commentsLabel } from "@/lib/feed-model"; +import { SparkleButton } from "./Buttons"; +import type { CurrentSlide } from "./MobileFeed"; +import type { CardPost } from "@/lib/feed-model"; + +const icon = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.7, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +}; + +export interface MobileDockProps { + /** The slide on screen; like and comments act on its post. */ + slide: CurrentSlide; + onToggleLike?: (post: CardPost) => void; + likeBusy?: boolean; + onOpenComments?: (postId: string) => void; + onOpenComposer?: () => void; + onOpenMore?: () => void; +} + +/** What the like and comment buttons show and whether they work, for the slide on screen. */ +export function dockState(slide: CurrentSlide, opts: { canLike?: boolean; likeBusy?: boolean } = {}) { + const post = slide.kind === "post" ? slide.post : null; + if (!post) { + return { post: null, liked: false, likes: null, comments: null, likeLabel: "Like", commentsLabel: "Comments", likeDisabled: true, commentsDisabled: true }; + } + return { + post, + liked: post.isLiked, + likes: post.likesCount, + comments: post.commentsCount, + likeLabel: post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`, + commentsLabel: `Open comments, ${commentsLabel(post.commentsCount).toLowerCase()}`, + likeDisabled: !post.id || !opts.canLike || !!opts.likeBusy, + commentsDisabled: !post.id, + }; +} + +/** + * Phone dock (spec "Mobile layout" > "Bottom dock"): a frosted bar 16px above the + * bottom. Like and comments act on the post on screen and are disabled on the + * featured and loading slides. Liked state and counts come from the post itself, + * the same source as the desktop like button and double-tap. + */ +export function MobileDock({ slide, onToggleLike, likeBusy, onOpenComments, onOpenComposer, onOpenMore }: MobileDockProps) { + const s = dockState(slide, { canLike: !!onToggleLike, likeBusy }); + const [beat, setBeat] = useState(false); + return ( + + ); +} diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 36020c5..f6c581c 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -7,6 +7,7 @@ import { HexAvatar } from "./HexAvatar"; import { PlayIcon, PostMenu } from "./PostCard"; import { VideoPlayer } from "./VideoPlayer"; import { FeaturedCarousel } from "./FeaturedCarousel"; +import { MobileDock } from "./MobileDock"; import type { DiscoverExpert } from "@/lib/discover"; import type { FeedViewProps } from "./FeedView"; @@ -415,6 +416,16 @@ export function MobileFeed(p: FeedViewProps) { /> ) : null} + {p.status !== "error" ? ( + + ) : null} ); } 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;/); + }); +});