- {preview && isVideo ? (
-
- ) : preview ? (
- // eslint-disable-next-line @next/next/no-img-element
-

- ) : null}
-
{file.name}
-
+
);
}
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx
index 10afa2c..ae0dfd1 100644
--- a/src/components/Feed.tsx
+++ b/src/components/Feed.tsx
@@ -6,11 +6,21 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { 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 { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000;
-export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
+export function Feed({
+ onSignedOut,
+ feedUserId,
+ person,
+}: {
+ onSignedOut: () => void;
+ feedUserId?: string | null;
+ /** The signed-in person, shown beside the composer. */
+ person?: ComposerProps["person"];
+}) {
const [posts, setPosts] = useState
([]);
const [status, setStatus] = useState("loading");
const [nextCursor, setNextCursor] = useState(null);
@@ -246,7 +256,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
deleteBusy={deleteBusy}
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
- composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
+ composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
sentinelRef={sentinel}
listRef={list}
/>
diff --git a/src/components/composer-text.ts b/src/components/composer-text.ts
new file mode 100644
index 0000000..4827fde
--- /dev/null
+++ b/src/components/composer-text.ts
@@ -0,0 +1,69 @@
+// Plain-text helpers for the composer (spec "Composer", formatting decided by
+// Cesar on 2026-10-19: posts are plain text, no bold or italic).
+
+export const BULLET = "⢠";
+
+export interface TextEdit {
+ text: string;
+ selectionStart: number;
+ selectionEnd: number;
+}
+
+/**
+ * Insert the plain character "⢠" at the start of the line holding the caret,
+ * or of every line the selection touches. A line that already starts with
+ * "⢠" is left as it is. Nothing else in the text changes. The returned
+ * selection is shifted so it still covers the same characters.
+ */
+export function insertBullets(text: string, selectionStart: number, selectionEnd: number): TextEdit {
+ const start = Math.max(0, Math.min(selectionStart, selectionEnd, text.length));
+ let end = Math.max(0, Math.min(Math.max(selectionStart, selectionEnd), text.length));
+ // A selection that ends right after a line break does not include the next line.
+ if (end > start && text[end - 1] === "\n") end -= 1;
+
+ // Offsets of every line start from the caret's line to the selection's last line.
+ const firstLine = text.lastIndexOf("\n", start - 1) + 1;
+ const lineStarts: number[] = [];
+ for (let i = firstLine; i <= end; ) {
+ lineStarts.push(i);
+ const next = text.indexOf("\n", i);
+ if (next === -1 || next >= end) break;
+ i = next + 1;
+ }
+
+ let out = "";
+ let last = 0;
+ let newStart = selectionStart;
+ let newEnd = selectionEnd;
+ for (const at of lineStarts) {
+ out += text.slice(last, at);
+ last = at;
+ if (text.startsWith(BULLET, at)) continue;
+ out += BULLET;
+ if (at <= selectionStart) newStart += BULLET.length;
+ if (at <= selectionEnd) newEnd += BULLET.length;
+ }
+ out += text.slice(last);
+ return { text: out, selectionStart: newStart, selectionEnd: newEnd };
+}
+
+/** "2.4 MB", "820 KB" or "512 B". */
+export function formatBytes(bytes: number): string {
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${bytes} B`;
+}
+
+/** The attachment row's meta line: type and size, for example "Image, 2.4 MB". */
+export function attachmentMeta(file: { type: string; size: number }): string {
+ const kind = file.type.startsWith("video/") ? "Video" : "Image";
+ return `${kind}, ${formatBytes(file.size)}`;
+}
+
+/** The 32 frequent emoji in the composer's emoji popover (mockup order). */
+export const COMPOSER_EMOJI = [
+ "š", "š", "š", "š„³", "š", "š¤", "š", "š",
+ "š„", "š”", "š", "šÆ", "ā
", "ā¤ļø", "šÆ", "āØ",
+ "š", "š", "š", "š§ ", "š¬", "š£", "š", "ā",
+ "š", "š", "š¤", "šŖ", "š±", "ā”", "š", "š",
+] as const;
diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx
index b84fde7..a9ced78 100644
--- a/src/dev/FeedPreview.tsx
+++ b/src/dev/FeedPreview.tsx
@@ -23,6 +23,7 @@ import {
export function FeedPreview() {
const [posts, setPosts] = useState(() => toCardPosts(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
+ const [file, setFile] = useState(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState(null);
@@ -84,7 +85,20 @@ export function FeedPreview() {
onToggleLike={toggleLike}
feedUserId={FIXTURE_PERSON.feedUserId}
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
- composer={{ value: draft, busy: false, error: false, onChange: setDraft, onSubmit: () => setDraft("") }}
+ composer={{
+ value: draft,
+ busy: false,
+ error: false,
+ file,
+ onChange: setDraft,
+ onFile: setFile,
+ // No network here: "publishing" just clears the composer.
+ onSubmit: () => {
+ setDraft("");
+ setFile(null);
+ },
+ person: FIXTURE_PERSON,
+ }}
now={FIXTURE_NOW}
/>
}
diff --git a/src/styles/composer.css b/src/styles/composer.css
new file mode 100644
index 0000000..88a533b
--- /dev/null
+++ b/src/styles/composer.css
@@ -0,0 +1,222 @@
+/*
+ * Composer, toolbar, emoji popover and attachment row (spec "Composer",
+ * ADR 0001). Ported from the mockup's .cmp, .bar, .emo and .att rules.
+ * Tokens only. The Post button is the flip reveal from buttons.css.
+ */
+.composer {
+ --composer-soft: color-mix(in srgb, var(--ink), transparent 93%);
+ position: relative;
+ border-radius: var(--r-card);
+ background: var(--tile-2);
+ box-shadow: inset 0 0 0 1px var(--line);
+ padding: 10px 10px 8px 12px;
+ display: flex;
+ flex-direction: column;
+ transition: box-shadow .3s, background-color .3s;
+}
+.composer.composer-open {
+ background: var(--tile);
+ box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
+}
+
+.composer-top { display: flex; gap: 12px; align-items: flex-start; }
+.composer-top .hex-avatar { flex: none; }
+.composer .composer-label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+.composer textarea {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ border: 0;
+ outline: 0;
+ resize: none;
+ background: transparent;
+ font: inherit;
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--ink);
+ padding: 12px 2px;
+ height: 50px;
+ transition: height .3s var(--ease);
+}
+.composer.composer-open textarea { height: 92px; }
+.composer textarea::placeholder { color: var(--ink-3); }
+.composer textarea:disabled { color: var(--ink-2); }
+
+/* Toolbar: photo, video, emoji | bulleted list ... Ctrl + Enter, Post. */
+.composer-bar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-top: 6px;
+ padding-top: 8px;
+ border-top: 1px solid var(--line);
+}
+.composer-tool {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--r-ctl);
+ display: inline-grid;
+ place-items: center;
+ color: var(--ink-2);
+ cursor: pointer;
+ flex: none;
+ transition: background-color .2s, color .2s, transform .15s;
+}
+.composer-tool svg { width: 20px; height: 20px; }
+.composer-tool:hover:not(:disabled),
+.composer-tool.composer-tool-on { background: var(--composer-soft); color: var(--ink); }
+.composer-tool:active:not(:disabled) { transform: scale(.9); }
+.composer-tool:disabled { cursor: not-allowed; color: var(--ink-3); background: transparent; }
+.composer-file {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ border: 0;
+}
+.composer-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 6px; flex: none; }
+.composer-spacer { flex: 1; }
+.composer-kbd { font-size: 11.5px; color: var(--ink-3); margin-right: 10px; white-space: nowrap; }
+.composer-kbd b {
+ font-weight: 700;
+ color: var(--ink-2);
+ padding: 1px 6px;
+ border-radius: 6px;
+ background: var(--tile-2);
+ box-shadow: inset 0 0 0 1px var(--line);
+}
+
+/* Emoji: a frosted popover with 32 frequent emoji. */
+.composer-emoji-wrap { position: relative; }
+.composer-emoji {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 8px);
+ left: -8px;
+ width: 320px;
+ padding: 10px;
+ background: var(--glass);
+ -webkit-backdrop-filter: blur(20px) saturate(1.7);
+ backdrop-filter: blur(20px) saturate(1.7);
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ box-shadow: var(--shadow-pop);
+ animation: composer-pop .24s cubic-bezier(.2,.9,.3,1.25) both;
+}
+.composer-eyebrow {
+ display: block;
+ padding: 2px 6px 8px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .18em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+.composer-emoji-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; }
+.composer-emoji-grid button {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--ink);
+ height: 36px;
+ border-radius: 10px;
+ font-family: inherit;
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+ transition: transform .15s cubic-bezier(.2,.9,.3,1.4), background-color .15s;
+}
+.composer-emoji-grid button:hover { background: var(--composer-soft); transform: scale(1.2); }
+
+/* Attachment row: thumbnail, name, type and size, progress, remove. */
+.composer-att {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 6px 0 2px;
+ padding: 8px;
+ border-radius: 14px;
+ background: var(--tile-2);
+ box-shadow: inset 0 0 0 1px var(--line);
+ animation: composer-rise .35s var(--ease) both;
+}
+.composer-att.composer-att-indent { margin-left: 58px; }
+.composer-thumb {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ border-radius: 10px;
+ flex: none;
+ overflow: hidden;
+ background: var(--tile-3);
+}
+.composer-thumb img,
+.composer-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
+.composer-thumb svg {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 20px;
+ height: 20px;
+ color: var(--on-accent);
+ filter: drop-shadow(0 1px 3px var(--accent-deep));
+}
+.composer-att-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+.composer-att-text strong {
+ font-size: 13px;
+ color: var(--ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.composer-att-text span { font-size: 12px; color: var(--ink-3); }
+.composer-prog { height: 3px; border-radius: 3px; background: var(--tile-3); overflow: hidden; }
+.composer-prog i {
+ display: block;
+ height: 100%;
+ background: var(--accent);
+ transform-origin: left;
+ animation: composer-load 1.6s var(--ease) both;
+}
+/* While posting the upload has no byte count, so the bar sweeps. */
+.composer-prog.composer-prog-busy i { width: 40%; animation: composer-sweep 1.1s ease-in-out infinite; }
+
+.composer .error { margin: 8px 0 0; }
+
+/* The flip Post button keeps its own size inside the toolbar. */
+.composer-bar .composer-post { flex: none; }
+
+@keyframes composer-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
+@keyframes composer-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
+@keyframes composer-load { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+@keyframes composer-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
+
+@media (prefers-reduced-motion: reduce) {
+ .composer,
+ .composer textarea,
+ .composer-tool,
+ .composer-emoji-grid button { transition: none !important; }
+ .composer-emoji,
+ .composer-att,
+ .composer-prog i { animation: none !important; }
+}
diff --git a/src/styles/legacy.css b/src/styles/legacy.css
index 1480c3c..d89b02b 100644
--- a/src/styles/legacy.css
+++ b/src/styles/legacy.css
@@ -16,20 +16,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
@keyframes spin { to { transform: rotate(360deg); } }
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
.sentinel { height: 1px; }
-.composer { background: var(--tile); border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
-.composer-label { margin: 0; }
-.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid var(--line-2); border-radius: 0; resize: vertical; background: var(--tile-2); 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; }
-.composer-media { display: flex; flex-direction: column; gap: 8px; }
-.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; }
-.composer-image span { padding: 6px 12px; border: 1px solid var(--line-2); background: var(--tile-2); }
-.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
-.composer-preview { display: flex; align-items: center; gap: 10px; }
-.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: black; }
-.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); }
-.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; }
diff --git a/tests/composer.test.ts b/tests/composer.test.ts
new file mode 100644
index 0000000..a556d3f
--- /dev/null
+++ b/tests/composer.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { Composer, isPublishShortcut } from "@/components/Composer";
+import { attachmentMeta, BULLET, COMPOSER_EMOJI, formatBytes, insertBullets } from "@/components/composer-text";
+
+describe("insertBullets", () => {
+ it("adds a bullet at the start of the caret's line", () => {
+ expect(insertBullets("hello", 3, 3)).toEqual({ text: "⢠hello", selectionStart: 5, selectionEnd: 5 });
+ expect(insertBullets("one\ntwo\nthree", 5, 5).text).toBe("one\n⢠two\nthree");
+ });
+ it("adds a bullet to an empty text and to an empty last line", () => {
+ expect(insertBullets("", 0, 0)).toEqual({ text: BULLET, selectionStart: 2, selectionEnd: 2 });
+ expect(insertBullets("a\n", 2, 2).text).toBe("a\n⢠");
+ });
+ it("adds a bullet to every selected line and keeps the selection on the same characters", () => {
+ const r = insertBullets("one\ntwo\nthree", 1, 9);
+ expect(r.text).toBe("⢠one\n⢠two\n⢠three");
+ expect(r.text.slice(r.selectionStart, r.selectionEnd)).toBe("ne\n⢠two\n⢠t");
+ });
+ it("does not bullet the next line when the selection ends right after a line break", () => {
+ expect(insertBullets("one\ntwo", 0, 4).text).toBe("⢠one\ntwo");
+ });
+ it("leaves lines that already start with a bullet unchanged", () => {
+ expect(insertBullets("⢠one\ntwo", 0, 9).text).toBe("⢠one\n⢠two");
+ expect(insertBullets("⢠one", 3, 3).text).toBe("⢠one");
+ });
+ it("changes nothing but the inserted characters", () => {
+ const text = "**bold** _it_\n indented";
+ const r = insertBullets(text, 0, text.length);
+ expect(r.text.replaceAll(BULLET, "")).toBe(text);
+ });
+});
+
+describe("composer helpers", () => {
+ it("offers 32 distinct emoji", () => {
+ expect(COMPOSER_EMOJI).toHaveLength(32);
+ expect(new Set(COMPOSER_EMOJI).size).toBe(32);
+ });
+ it("describes an attachment by type and size", () => {
+ expect(formatBytes(512)).toBe("512 B");
+ expect(formatBytes(2048)).toBe("2 KB");
+ expect(attachmentMeta({ type: "image/png", size: 2.4 * 1024 * 1024 })).toBe("Image, 2.4 MB");
+ expect(attachmentMeta({ type: "video/mp4", size: 18.2 * 1024 * 1024 })).toBe("Video, 18.2 MB");
+ });
+ it("publishes on Ctrl + Enter and Cmd + Enter only", () => {
+ expect(isPublishShortcut({ key: "Enter", ctrlKey: true, metaKey: false })).toBe(true);
+ expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: true })).toBe(true);
+ expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: false })).toBe(false);
+ expect(isPublishShortcut({ key: "a", ctrlKey: true, metaKey: false })).toBe(false);
+ });
+});
+
+describe("Composer markup", () => {
+ const base = { value: "", busy: false, error: false, onChange: () => {}, onSubmit: () => {} };
+ const html = (p: Partial[0]>) => renderToStaticMarkup(createElement(Composer, { ...base, ...p }));
+
+ it("is collapsed when empty and open when holding text or a file", () => {
+ expect(html({})).toMatch(/class="composer"/);
+ expect(html({ value: "hi" })).toContain("composer composer-open");
+ const f = new File(["x"], "a.png", { type: "image/png" });
+ expect(html({ file: f, onFile: () => {} })).toContain("composer composer-open");
+ });
+ it("orders the toolbar: photo, video, emoji, divider, bulleted list, hint, Post", () => {
+ const out = html({ onFile: () => {} });
+ const order = ["Add image", "Add video", "Add emoji", "composer-sep", "Bulleted list", "composer-kbd", "btn-flip"];
+ const at = order.map((s) => out.indexOf(s));
+ expect(at.every((i) => i >= 0)).toBe(true);
+ expect([...at].sort((a, b) => a - b)).toEqual(at);
+ expect(out).not.toMatch(/aria-label="(Bold|Italic)"/);
+ });
+ it("shows the three Post states", () => {
+ expect(html({})).toMatch(/btn-flip-front">Post<.*btn-flip-back[^>]*>Write first/);
+ expect(html({ value: "hi" })).toMatch(/btn-flip-back[^>]*>Publish