diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts
new file mode 100644
index 0000000..0fbcebd
--- /dev/null
+++ b/src/app/api/posts/[id]/comments/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { createComment, listComments } from "@/lib/comments";
+import { SESSION_COOKIE } from "@/lib/cookie";
+
+// GET /api/posts/:id/comments -> feed GET /api/v1/posts/{id}/comments?limit=20&page=1
+// POST /api/posts/:id/comments -> feed POST /api/v1/posts/{id}/comments
+// body { content } -> feed { content, type: "text", status: "published" }
+// 401 { error: "sign_in" } without a session.
+
+export const runtime = "nodejs";
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function GET(req: NextRequest, ctx: Ctx) {
+ const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
+ const { id } = await ctx.params;
+ const r = await listComments(sessionId, id);
+ if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
+ return NextResponse.json({ comments: r.comments });
+}
+
+export async function POST(req: NextRequest, ctx: Ctx) {
+ const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
+ const { id } = await ctx.params;
+ let content: unknown = null;
+ try {
+ content = ((await req.json()) as { content?: unknown })?.content;
+ } catch {
+ content = null;
+ }
+ const r = await createComment(sessionId, id, content);
+ if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
+ return NextResponse.json({ comment: r.comment });
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 2c90508..08582dd 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -47,3 +47,17 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); }
.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; }
+
+.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; }
+.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
+.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; }
+.comment-head time { color: var(--grey); font-size: 12px; }
+.comment.removed .comment-removed { color: var(--grey); font-style: italic; }
+.comments-empty { margin: 0; color: var(--grey); font-size: 14px; }
+.comment-error { margin: 0; color: #a8141b; font-size: 13px; }
+.comment-form { display: flex; gap: 8px; }
+.comment-form input { flex: 1; margin: 0; }
+.comment-form button { margin: 0; }
+.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
+.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
diff --git a/src/components/CommentsPanel.tsx b/src/components/CommentsPanel.tsx
new file mode 100644
index 0000000..ab23296
--- /dev/null
+++ b/src/components/CommentsPanel.tsx
@@ -0,0 +1,61 @@
+import type { CommentItem } from "@/lib/comment-model";
+import { relativeTime } from "@/lib/feed-model";
+
+export interface CommentsPanelProps {
+ comments: CommentItem[];
+ loading?: boolean;
+ /** Error text naming the action that failed. The list stays shown. */
+ error?: string | null;
+ value: string;
+ busy?: boolean;
+ onChange?: (v: string) => void;
+ onSubmit?: () => void;
+ onRetry?: () => void;
+ now?: number;
+}
+
+export function CommentsPanel(p: CommentsPanelProps) {
+ const empty = !p.value.trim();
+ return (
+
+ {p.loading ?
: null}
+ {!p.loading && p.comments.length === 0 && !p.error ? (
+
No comments yet.
+ ) : null}
+
+ {p.error ? (
+
+ {p.error}{" "}
+ {p.onRetry ? : null}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/CommentsSection.tsx b/src/components/CommentsSection.tsx
new file mode 100644
index 0000000..0d13c03
--- /dev/null
+++ b/src/components/CommentsSection.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { CommentError, commentErrorText, loadComments, postComment } from "@/lib/comment-client";
+import type { CommentItem } from "@/lib/comment-model";
+import { CommentsPanel } from "./CommentsPanel";
+
+/** Opened comments for one post: loads on mount, posts a text comment into the list. */
+export function CommentsSection({ postId, onSignedOut }: { postId: string; onSignedOut?: () => void }) {
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [value, setValue] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const fail = useCallback(
+ (e: unknown, action: "load" | "post") => {
+ if (e instanceof CommentError && e.signedOut) onSignedOut?.();
+ setError(commentErrorText(action));
+ },
+ [onSignedOut],
+ );
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ setComments(await loadComments(postId));
+ } catch (e) {
+ fail(e, "load");
+ } finally {
+ setLoading(false);
+ }
+ }, [postId, fail]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const submit = useCallback(async () => {
+ if (!value.trim() || busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ setComments(await postComment(postId, value, comments));
+ setValue("");
+ } catch (e) {
+ fail(e, "post"); // the list and the typed text stay in place
+ } finally {
+ setBusy(false);
+ }
+ }, [postId, value, busy, comments, fail]);
+
+ return (
+ void submit()}
+ onRetry={error === commentErrorText("load") ? () => void load() : undefined}
+ />
+ );
+}
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx
index 8ed3f5d..0660b5c 100644
--- a/src/components/Feed.tsx
+++ b/src/components/Feed.tsx
@@ -199,6 +199,7 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy}
likeErrors={likeErrors}
+ onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }}
sentinelRef={sentinel}
/>
diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx
index f5f122b..0046891 100644
--- a/src/components/FeedView.tsx
+++ b/src/components/FeedView.tsx
@@ -16,6 +16,7 @@ export interface FeedViewProps {
onToggleLike?: (post: CardPost) => void;
likeBusy?: ReadonlySet;
likeErrors?: ReadonlySet;
+ onSignedOut?: () => void;
composer?: ComposerProps;
sentinelRef?: Ref;
now?: number;
@@ -63,6 +64,7 @@ export function FeedView(p: FeedViewProps) {
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
+ onSignedOut={p.onSignedOut}
/>)
)}
{p.loadMoreFailed ? (
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx
index 98c7fa3..5c9d130 100644
--- a/src/components/PostCard.tsx
+++ b/src/components/PostCard.tsx
@@ -1,3 +1,7 @@
+"use client";
+
+import { useState } from "react";
+import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
@@ -7,6 +11,7 @@ export interface PostCardProps {
onToggleLike?: (post: CardPost) => void;
likeBusy?: boolean;
likeError?: boolean;
+ onSignedOut?: () => void;
}
function Heart({ filled }: { filled: boolean }) {
@@ -19,7 +24,8 @@ function Heart({ filled }: { filled: boolean }) {
);
}
-export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostCardProps) {
+export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut }: PostCardProps) {
+ const [open, setOpen] = useState(false);
return (
@@ -60,11 +66,20 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostC
>
{post.likesCount}
- {post.commentsCount} comments
+
{likeError ? (
Could not update like.
) : null}
+ {open && post.id ? : null}
);
}
diff --git a/src/lib/comment-client.ts b/src/lib/comment-client.ts
new file mode 100644
index 0000000..616556a
--- /dev/null
+++ b/src/lib/comment-client.ts
@@ -0,0 +1,70 @@
+// Browser-side comment calls. Each throws CommentError naming the action.
+
+import { appendComment, type CommentItem } from "./comment-model";
+
+export type CommentAction = "load" | "post";
+
+export class CommentError extends Error {
+ constructor(public readonly action: CommentAction, public readonly signedOut: boolean) {
+ super(signedOut ? "sign_in" : `comment_${action}_failed`);
+ }
+}
+
+export function commentErrorText(action: CommentAction): string {
+ return action === "load" ? "Could not load comments." : "Could not post comment.";
+}
+
+async function parse(res: Response, action: CommentAction): Promise> {
+ if (res.status === 401) throw new CommentError(action, true);
+ if (!res.ok) throw new CommentError(action, false);
+ try {
+ return (await res.json()) as Record;
+ } catch {
+ throw new CommentError(action, false);
+ }
+}
+
+export async function loadComments(postId: string, fetchImpl: typeof fetch = fetch): Promise {
+ let res: Response;
+ try {
+ res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, { credentials: "same-origin" });
+ } catch {
+ throw new CommentError("load", false);
+ }
+ const b = await parse(res, "load");
+ return Array.isArray(b.comments) ? (b.comments as CommentItem[]) : [];
+}
+
+/** Post a comment and return the list as it should now read. Throws and returns nothing on failure. */
+export async function postComment(
+ postId: string,
+ text: string,
+ current: CommentItem[],
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const content = text.trim();
+ if (!content) return current;
+ let res: Response;
+ try {
+ res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ content }),
+ });
+ } catch {
+ throw new CommentError("post", false);
+ }
+ const b = await parse(res, "post");
+ const made = b.comment as CommentItem | null | undefined;
+ if (made && typeof made === "object") return appendComment(current, made);
+ // The feed did not return the new comment; read the list back.
+ try {
+ return await loadComments(postId, fetchImpl);
+ } catch {
+ return appendComment(current, {
+ key: `local#${current.length}`, id: null, authorName: "You",
+ createdAt: new Date().toISOString(), removed: false, content,
+ });
+ }
+}
diff --git a/src/lib/comment-model.ts b/src/lib/comment-model.ts
new file mode 100644
index 0000000..c83202b
--- /dev/null
+++ b/src/lib/comment-model.ts
@@ -0,0 +1,71 @@
+// Pure comment model: normalise raw feed comments into what the panel shows.
+// A comment with deletedAt set is "removed" and never carries its old text.
+
+export interface CommentItem {
+ key: string;
+ id: string | null;
+ authorName: string;
+ createdAt: string | null;
+ removed: boolean;
+ /** Empty when removed. */
+ content: string;
+}
+
+type Obj = Record;
+
+function isObj(v: unknown): v is Obj {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+function str(v: unknown): string | null {
+ return typeof v === "string" && v.trim() ? v : null;
+}
+
+function nameOf(author: Obj): string {
+ const display = str(author.displayName);
+ if (display) return display.trim();
+ const full = [str(author.firstName), str(author.lastName)].filter(Boolean).join(" ").trim();
+ return full || "Unknown";
+}
+
+export function toComment(raw: unknown, index = 0): CommentItem | null {
+ if (!isObj(raw)) return null;
+ const id = typeof raw.id === "string" || typeof raw.id === "number" ? String(raw.id) : null;
+ const createdAt = str(raw.createdAt);
+ const removed = raw.deletedAt !== null && raw.deletedAt !== undefined && raw.deletedAt !== false && raw.deletedAt !== "";
+ const author = isObj(raw.author) ? raw.author : isObj(raw.user) ? raw.user : {};
+ return {
+ key: id ?? `${createdAt ?? "unknown"}#${index}`,
+ id,
+ authorName: nameOf(author),
+ createdAt,
+ removed,
+ content: removed ? "" : typeof raw.content === "string" ? raw.content : "",
+ };
+}
+
+/** Find the comment array in a feed response body. */
+export function commentsFromBody(body: unknown): unknown[] {
+ const root = isObj(body) ? body : {};
+ const data = root.data;
+ const candidates: unknown[] = [
+ data, isObj(data) ? data.comments : null, isObj(data) ? data.items : null,
+ root.comments, root.items,
+ ];
+ for (const c of candidates) if (Array.isArray(c)) return c;
+ return [];
+}
+
+export function toComments(raws: unknown[]): CommentItem[] {
+ const out: CommentItem[] = [];
+ raws.forEach((r, i) => {
+ const c = toComment(r, i);
+ if (c) out.push(c);
+ });
+ return out;
+}
+
+/** Add a comment to the end of the list unless its id is already there. */
+export function appendComment(list: CommentItem[], c: CommentItem): CommentItem[] {
+ if (c.id && list.some((x) => x.id === c.id)) return list;
+ return [...list, c];
+}
diff --git a/src/lib/comments.ts b/src/lib/comments.ts
new file mode 100644
index 0000000..93a2d91
--- /dev/null
+++ b/src/lib/comments.ts
@@ -0,0 +1,116 @@
+import { FEED_BASE_URL } from "./env";
+import { commentsFromBody, toComment, toComments, type CommentItem } from "./comment-model";
+import { ensureFreshSession, refreshSession } from "./session-refresh";
+
+// Comment list / create proxy. One feed call per request with the person's
+// server-held feed token, plus one refresh-and-retry after a 401 rejection.
+// Neither the token nor comment text is logged.
+
+export const MAX_COMMENT_LENGTH = 5_000;
+const POST_ID = /^[A-Za-z0-9_-]{1,128}$/;
+
+type Err = { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | E };
+export type ListCommentsResult = { ok: true; comments: CommentItem[] } | Err<"comments_unavailable">;
+export type CreateCommentResult =
+ | { ok: true; comment: CommentItem | null }
+ | Err<"empty_comment" | "comment_failed">;
+
+interface Options {
+ fetchImpl?: typeof fetch;
+ now?: number;
+}
+
+function rec(v: unknown): Record {
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {};
+}
+
+function url(postId: string, query = "") {
+ return `${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/comments${query}`;
+}
+
+async function callWithRefresh(
+ sessionId: string,
+ first: Awaited>,
+ make: (token: string) => Promise,
+ options: Options,
+): Promise {
+ let session = first;
+ if (!session) return "sign_in";
+ let res = await make(session.feedAccessToken);
+ if (res.status === 401) {
+ session = await refreshSession(sessionId, options);
+ if (!session) return "sign_in";
+ res = await make(session.feedAccessToken);
+ if (res.status === 401) return "sign_in";
+ }
+ return res;
+}
+
+export async function listComments(
+ sessionId: string | undefined,
+ postId: string,
+ options: Options = {},
+): Promise {
+ const signIn = { ok: false, status: 401, error: "sign_in" } as const;
+ const failed = { ok: false, status: 502, error: "comments_unavailable" } as const;
+ const doFetch = options.fetchImpl ?? fetch;
+ const session = await ensureFreshSession(sessionId, options);
+ if (!session || !sessionId) return signIn;
+ if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
+ try {
+ const res = await callWithRefresh(
+ sessionId, session,
+ (t) => doFetch(url(postId, "?limit=20&page=1"), {
+ method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${t}` },
+ }),
+ options,
+ );
+ if (res === "sign_in") return signIn;
+ if (res.status !== 200) return failed;
+ return { ok: true, comments: toComments(commentsFromBody(await res.json())) };
+ } catch {
+ return failed;
+ }
+}
+
+export async function createComment(
+ sessionId: string | undefined,
+ postId: string,
+ content: unknown,
+ options: Options = {},
+): Promise {
+ const signIn = { ok: false, status: 401, error: "sign_in" } as const;
+ const failed = { ok: false, status: 502, error: "comment_failed" } as const;
+ const doFetch = options.fetchImpl ?? fetch;
+ const session = await ensureFreshSession(sessionId, options);
+ if (!session || !sessionId) return signIn;
+ if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
+ if (typeof content !== "string" || !content.trim() || content.length > MAX_COMMENT_LENGTH) {
+ return { ok: false, status: 400, error: "empty_comment" };
+ }
+ try {
+ const res = await callWithRefresh(
+ sessionId, session,
+ (t) => doFetch(url(postId), {
+ method: "POST",
+ headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${t}` },
+ body: JSON.stringify({ content, type: "text", status: "published" }),
+ }),
+ options,
+ );
+ if (res === "sign_in") return signIn;
+ if (res.status !== 200 && res.status !== 201) return failed;
+ let comment: CommentItem | null = null;
+ try {
+ const body = rec(await res.json());
+ const data = rec(body.data);
+ const raw = Object.keys(rec(data.comment)).length ? data.comment : data.id !== undefined ? data : null;
+ comment = raw ? toComment(raw) : null;
+ } catch {
+ comment = null;
+ }
+ return { ok: true, comment };
+ } catch {
+ return failed;
+ }
+}
diff --git a/tests/comments.test.ts b/tests/comments.test.ts
new file mode 100644
index 0000000..5e5276d
--- /dev/null
+++ b/tests/comments.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it, vi } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { NextRequest } from "next/server";
+import { GET, POST } from "@/app/api/posts/[id]/comments/route";
+import { CommentsPanel } from "@/components/CommentsPanel";
+import { CommentError, commentErrorText, loadComments, postComment } from "@/lib/comment-client";
+import { toComments } from "@/lib/comment-model";
+import { createSession } from "@/lib/session-store";
+
+const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
+
+function session() {
+ return createSession({
+ profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r",
+ feedAccessToken: "T", feedAccessTokenExpiresAt: null,
+ person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(),
+ });
+}
+
+async function withFetch(fn: typeof fetch, run: () => Promise) {
+ const orig = globalThis.fetch;
+ globalThis.fetch = fn;
+ try { return await run(); } finally { globalThis.fetch = orig; }
+}
+
+const raw = [
+ { id: "c1", content: "first", createdAt: "2026-10-19T10:00:00Z", author: { displayName: "Ana Ruiz" } },
+ { id: "c2", content: "SECRET OLD TEXT", createdAt: "2026-10-19T10:05:00Z", deletedAt: "2026-10-19T11:00:00Z", author: { firstName: "Bo", lastName: "Li" } },
+];
+
+describe("comment routes", () => {
+ it("401 sign_in without a session", async () => {
+ const g = await GET(new NextRequest("http://localhost/api/posts/p1/comments"), ctx("p1"));
+ expect(g.status).toBe(401);
+ expect(await g.json()).toEqual({ error: "sign_in" });
+ const p = await POST(new NextRequest("http://localhost/x", { method: "POST", body: JSON.stringify({ content: "hi" }) }), ctx("p1"));
+ expect(p.status).toBe(401);
+ expect(await p.json()).toEqual({ error: "sign_in" });
+ });
+
+ it("GET reads the feed list with limit and page; removed comments lose their text", async () => {
+ const sid = session();
+ const urls: string[] = [];
+ const f = vi.fn(async (u: string) => { urls.push(String(u)); return new Response(JSON.stringify({ data: { comments: raw } }), { status: 200 }); });
+ await withFetch(f as unknown as typeof fetch, async () => {
+ const r = await GET(new NextRequest("http://localhost/x", { headers: { cookie: `mfb_session=${sid}` } }), ctx("p1"));
+ const body = await r.json();
+ expect(body.comments).toHaveLength(2);
+ expect(body.comments[0]).toMatchObject({ content: "first", authorName: "Ana Ruiz" });
+ expect(body.comments[1]).toMatchObject({ removed: true, content: "", authorName: "Bo Li" });
+ expect(JSON.stringify(body)).not.toContain("SECRET OLD TEXT");
+ });
+ expect(urls[0]).toMatch(/\/api\/v1\/posts\/p1\/comments\?limit=20&page=1$/);
+ });
+
+ it("POST sends the text comment payload and rejects empty content without calling the feed", async () => {
+ const sid = session();
+ const h = { cookie: `mfb_session=${sid}` };
+ const sent: unknown[] = [];
+ const f = vi.fn(async (_u: string, init?: RequestInit) => {
+ sent.push(JSON.parse(String(init?.body)));
+ return new Response(JSON.stringify({ data: { id: "c9", content: "hello", createdAt: "2026-10-19T12:00:00Z", author: { displayName: "Me" } } }), { status: 201 });
+ });
+ await withFetch(f as unknown as typeof fetch, async () => {
+ const ok = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h, body: JSON.stringify({ content: "hello" }) }), ctx("p1"));
+ expect((await ok.json()).comment).toMatchObject({ id: "c9", content: "hello" });
+ const empty = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h, body: JSON.stringify({ content: " " }) }), ctx("p1"));
+ expect(empty.status).toBe(400);
+ });
+ expect(sent).toEqual([{ content: "hello", type: "text", status: "published" }]);
+ expect(f).toHaveBeenCalledTimes(1);
+ });
+
+ it("a feed failure is 502 with the action in the error", async () => {
+ const sid = session();
+ const h = { cookie: `mfb_session=${sid}` };
+ const bad = vi.fn(async () => new Response("{}", { status: 500 })) as unknown as typeof fetch;
+ await withFetch(bad, async () => {
+ const g = await GET(new NextRequest("http://localhost/x", { headers: h }), ctx("p1"));
+ expect([g.status, await g.json()]).toEqual([502, { error: "comments_unavailable" }]);
+ const p = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h, body: JSON.stringify({ content: "x" }) }), ctx("p1"));
+ expect([p.status, await p.json()]).toEqual([502, { error: "comment_failed" }]);
+ });
+ });
+});
+
+describe("comments panel", () => {
+ it("opening lists comments with author and time; a removed one shows no old text", () => {
+ const html = renderToStaticMarkup(createElement(CommentsPanel, { comments: toComments(raw), value: "", now: Date.parse("2026-10-19T12:00:00Z") }));
+ expect(html).toContain("Ana Ruiz");
+ expect(html).toContain("first");
+ expect(html).toContain("2h ago");
+ expect(html).toContain("This comment was removed.");
+ expect(html).not.toContain("SECRET OLD TEXT");
+ });
+
+ it("empty or whitespace input disables submit", () => {
+ const off = renderToStaticMarkup(createElement(CommentsPanel, { comments: [], value: " " }));
+ expect(off).toMatch(/