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;/);
+ });
+});