From 63bd97b1ec4ee83f12185c8a77feeef3a312bd70 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 28 Jun 2026 09:23:13 -0400 Subject: [PATCH] feat(viewer): add anchored surface comments --- .changeset/surface-comment-anchors.md | 5 + server/app.ts | 67 ++++++ server/events.ts | 1 + server/mcpHttp.ts | 1 + server/sqlStore.ts | 29 ++- server/storage.ts | 12 ++ server/types.ts | 38 ++++ test/api.test.ts | 57 +++++ test/storeContract.ts | 38 ++++ viewer/src/Card.tsx | 289 ++++++++++++++++++++++---- viewer/src/api.ts | 2 + viewer/src/icons.tsx | 10 + viewer/src/state.ts | 16 ++ viewer/src/styles.css | 265 +++++++++++++++++++++++ 14 files changed, 785 insertions(+), 45 deletions(-) create mode 100644 .changeset/surface-comment-anchors.md diff --git a/.changeset/surface-comment-anchors.md b/.changeset/surface-comment-anchors.md new file mode 100644 index 0000000..8acf96e --- /dev/null +++ b/.changeset/surface-comment-anchors.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Add a proof-of-concept for anchored comments on surfaces. Comments can now carry sanitized surface anchor metadata, the viewer can place host-owned pins over rendered surfaces, and agent feedback includes anchor context. diff --git a/server/app.ts b/server/app.ts index 59ae46a..8db52f1 100644 --- a/server/app.ts +++ b/server/app.ts @@ -19,6 +19,7 @@ import { type AssetKind, type CodeSurface, type Comment, + type CommentAnchor, type DiffSurface, htmlSurface, type MarkdownSurface, @@ -265,6 +266,7 @@ export interface Feedback { surfaceTitle: string | null; text: string; at: string; + anchor?: CommentAnchor; } // Lean comment shape attached to agent-facing responses. @@ -273,6 +275,7 @@ const feedbackView = (c: Comment): Feedback => ({ surfaceTitle: c.postTitle, text: c.text, at: c.createdAt, + ...(c.anchor && { anchor: c.anchor }), }); export function createApp({ @@ -654,10 +657,65 @@ export function createApp({ return reviseSurface(id, { parts: indexed }); } + function numberInRange(value: unknown, min: number, max: number): number | null { + const n = Number(value); + return Number.isFinite(n) && n >= min && n <= max ? n : null; + } + + function sanitizeCommentAnchor(raw: unknown, post: Post): CommentAnchor | undefined { + if (!raw || typeof raw !== "object") return undefined; + const input = raw as Record; + const kind = input.kind === "rect" || input.kind === "lineRange" ? input.kind : "point"; + let surfaceIndex = Number(input.surfaceIndex); + if ( + !Number.isInteger(surfaceIndex) || + surfaceIndex < 0 || + surfaceIndex >= post.surfaces.length + ) { + const surfaceId = typeof input.surfaceId === "string" ? input.surfaceId : undefined; + surfaceIndex = post.surfaces.findIndex((s) => s.id === surfaceId); + } + if (surfaceIndex < 0 || surfaceIndex >= post.surfaces.length) return undefined; + const surface = post.surfaces[surfaceIndex]; + const base = { + surfaceIndex, + ...(surface.id && { surfaceId: surface.id }), + surfaceKind: surface.kind, + // The server pins anchors to the current stored version instead of trusting + // the client-supplied value. + postVersion: post.version, + }; + if (kind === "lineRange") { + const startLine = Number(input.startLine); + const endLine = Number(input.endLine); + if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1) { + return undefined; + } + return { + kind, + ...base, + startLine, + endLine: Math.max(startLine, endLine), + ...(typeof input.file === "string" && { file: input.file.slice(0, MAX_TITLE) }), + }; + } + const x = numberInRange(input.x, 0, 1); + const y = numberInRange(input.y, 0, 1); + if (x == null || y == null) return undefined; + if (kind === "rect") { + const w = numberInRange(input.w, 0, 1); + const h = numberInRange(input.h, 0, 1); + if (w == null || h == null) return undefined; + return { kind, ...base, x, y, w, h }; + } + return { kind: "point", ...base, x, y }; + } + async function createComment(input: { text: string; surface?: string; author: string; + anchor?: unknown; }): Promise< { comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 } > { @@ -671,6 +729,7 @@ export function createApp({ postId: surface.id, author: input.author, text: input.text.trim().slice(0, MAX_COMMENT_TEXT), + anchor: sanitizeCommentAnchor(input.anchor, surface), }); if (!comment) return { error: "session not found", status: 404 }; bus.broadcast({ @@ -1274,6 +1333,7 @@ export function createApp({ text: body.text, surface: typeof surface === "string" ? surface : undefined, author: typeof body.author === "string" ? body.author : "user", + anchor: body.anchor, }); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json( @@ -1282,6 +1342,13 @@ export function createApp({ ); }); + app.delete("/api/comments/:id", async (c) => { + const comment = await store.removeComment(c.req.param("id")); + if (!comment) return c.json({ error: "comment not found" }, 404); + bus.broadcast({ type: "comment-deleted", id: comment.id, sessionId: comment.sessionId }); + return c.json({ ok: true }); + }); + // The viewer's update notice: running version vs latest published release. app.get("/api/version", async (c) => { if (!version) return c.json({ current: null, latest: null, updateAvailable: false }); diff --git a/server/events.ts b/server/events.ts index 130a7f7..9e85008 100644 --- a/server/events.ts +++ b/server/events.ts @@ -9,6 +9,7 @@ export type FeedEvent = surfaceId: string | null; seq: number; } + | { type: "comment-deleted"; id: string; sessionId: string } // Workspace theme changed; `id` is the new theme id. Other open tabs re-theme. | { type: "theme-changed"; id: string } // Session-scoped agent trace gained steps (synced in a batch). Carries only diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 7d2b783..ae69f01 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -151,6 +151,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { surfaceTitle: c.postTitle, text: c.text, at: c.createdAt, + ...(c.anchor && { anchor: c.anchor }), })), lastSeq: result.lastSeq, }, diff --git a/server/sqlStore.ts b/server/sqlStore.ts index dad15e4..12838ae 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -81,6 +81,10 @@ export class SqlStore implements Store { if (!sessionCols.some((c) => c.name === "agentSeq")) { this.sql.exec("ALTER TABLE sessions ADD COLUMN agentSeq INTEGER NOT NULL DEFAULT 0"); } + const commentCols = this.sql.exec("SELECT name FROM pragma_table_info('comments')").toArray(); + if (!commentCols.some((c) => c.name === "anchor")) { + this.sql.exec("ALTER TABLE comments ADD COLUMN anchor TEXT"); + } this.migrateToSurfaces(); this.migrateToPosts(); this.migrateSurfaceIds(); @@ -245,6 +249,14 @@ export class SqlStore implements Store { } private rowToComment(r: Record): Comment { + let anchor: Comment["anchor"] | undefined; + if (typeof r.anchor === "string" && r.anchor) { + try { + anchor = JSON.parse(r.anchor) as Comment["anchor"]; + } catch { + anchor = undefined; + } + } return { id: r.id as string, seq: r.seq as number, @@ -254,6 +266,7 @@ export class SqlStore implements Store { author: r.author as string, text: r.text as string, createdAt: r.createdAt as string, + ...(anchor && { anchor }), }; } @@ -484,7 +497,7 @@ export class SqlStore implements Store { const author = stripNul(input.author).trim() || "user"; const text = stripNul(input.text); this.sql.exec( - "INSERT INTO comments (id, sessionId, postId, postTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO comments (id, sessionId, postId, postTitle, author, text, createdAt, anchor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id, input.sessionId, surface?.id ?? null, @@ -492,6 +505,7 @@ export class SqlStore implements Store { author, text, createdAt, + input.anchor ? JSON.stringify(input.anchor) : null, ); const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq as number; this.touch(input.sessionId); @@ -504,9 +518,19 @@ export class SqlStore implements Store { author, text, createdAt, + ...(input.anchor && { anchor: input.anchor }), }; } + async removeComment(id: string) { + const rows = this.sql.exec("SELECT * FROM comments WHERE id = ?", id).toArray(); + if (rows.length === 0) return null; + const comment = this.rowToComment(rows[0]); + this.sql.exec("DELETE FROM comments WHERE id = ?", id); + this.touch(comment.sessionId); + return comment; + } + // --- trace --- private rowToTraceStep(r: Record): TraceStep { @@ -700,7 +724,7 @@ export class SqlStore implements Store { } for (const c of snapshot.comments) { this.sql.exec( - "INSERT INTO comments (seq, id, sessionId, postId, postTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO comments (seq, id, sessionId, postId, postTitle, author, text, createdAt, anchor) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", c.seq, c.id, c.sessionId, @@ -709,6 +733,7 @@ export class SqlStore implements Store { c.author, c.text, c.createdAt, + c.anchor ? JSON.stringify(c.anchor) : null, ); } for (const t of snapshot.traces) { diff --git a/server/storage.ts b/server/storage.ts index c2b565a..27a6b9b 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -107,6 +107,7 @@ function liftComment(c: LegacyComment): Comment { author: c.author, text: c.text, createdAt: c.createdAt, + ...(c.anchor && { anchor: c.anchor }), }; } @@ -433,6 +434,7 @@ export class JsonFileStore implements Store { author: stripNul(input.author).trim() || "user", text: stripNul(input.text), createdAt: new Date().toISOString(), + ...(input.anchor && { anchor: input.anchor }), }; this.comments.push(comment); this.touch(input.sessionId); @@ -440,6 +442,16 @@ export class JsonFileStore implements Store { return clone(comment); } + async removeComment(id: string) { + await this.load(); + const idx = this.comments.findIndex((c) => c.id === id); + if (idx < 0) return null; + const [comment] = this.comments.splice(idx, 1); + this.touch(comment.sessionId); + await this.persist(); + return clone(comment); + } + // --- trace --- async listTrace(sessionId: string) { diff --git a/server/types.ts b/server/types.ts index 9391bc9..8c75c54 100644 --- a/server/types.ts +++ b/server/types.ts @@ -192,6 +192,38 @@ export interface Post { history: PostVersion[]; } +export type CommentAnchor = + | { + kind: "point"; + surfaceIndex: number; + surfaceId?: string; + surfaceKind?: SurfaceKind; + postVersion: number; + x: number; + y: number; + } + | { + kind: "rect"; + surfaceIndex: number; + surfaceId?: string; + surfaceKind?: SurfaceKind; + postVersion: number; + x: number; + y: number; + w: number; + h: number; + } + | { + kind: "lineRange"; + surfaceIndex: number; + surfaceId?: string; + surfaceKind?: SurfaceKind; + postVersion: number; + startLine: number; + endLine: number; + file?: string; + }; + export interface Comment { id: string; seq: number; @@ -201,6 +233,10 @@ export interface Comment { author: string; text: string; createdAt: string; + // Optional host-authored anchor for comments on a specific rendered surface + // area/line. It is data only: render with text/positioned elements in the + // trusted viewer, never as HTML. + anchor?: CommentAnchor; } // An uploaded blob (image, trace file, arbitrary file) the agent pushes once and @@ -252,6 +288,7 @@ export interface CreateCommentInput { postId?: string; author: string; text: string; + anchor?: CommentAnchor; } export interface CommentQuery { @@ -284,6 +321,7 @@ export interface Store { listComments(query: CommentQuery): Promise; createComment(input: CreateCommentInput): Promise; + removeComment(id: string): Promise; // Session-scoped agent trace: the steps that produced a session's surfaces, // synced from the transcript. setTrace replaces the whole list (windowed diff --git a/test/api.test.ts b/test/api.test.ts index 00b7939..acdbc33 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -688,6 +688,63 @@ test("comments attach to snippets and filter by author/after", async () => { assert.equal(later.comments.length, 0); }); +test("comments can carry sanitized surface anchors", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Anchors", + surfaces: [ + { kind: "html", html: "

x

" }, + { kind: "markdown", markdown: "# y" }, + ], + }), + ) + ).json()) as any; + const post = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + + await app.request( + "/api/comments", + json({ + surface: post.id, + text: "look here", + author: "user", + anchor: { kind: "point", surfaceIndex: 1, x: 0.25, y: 0.75, postVersion: 999 }, + }), + ); + + const all = (await (await app.request(`/api/comments?session=${post.sessionId}`)).json()) as any; + assert.deepEqual(all.comments[0].anchor, { + kind: "point", + surfaceIndex: 1, + surfaceId: post.surfaces[1].id, + surfaceKind: "markdown", + postVersion: 1, + x: 0.25, + y: 0.75, + }); +}); + +test("comments can be deleted", async () => { + const app = makeApp(); + const s = (await (await app.request("/api/snippets", json({ html: "

x

" }))).json()) as any; + const created = (await ( + await app.request("/api/comments", json({ snippet: s.id, text: "remove me", author: "user" })) + ).json()) as any; + + assert.equal( + (await app.request(`/api/comments/${created.id}`, { method: "DELETE" })).status, + 200, + ); + const all = (await (await app.request(`/api/comments?session=${s.sessionId}`)).json()) as any; + assert.equal(all.comments.length, 0); + assert.equal( + (await app.request(`/api/comments/${created.id}`, { method: "DELETE" })).status, + 404, + ); +}); + test("a comment must target a surface", async () => { const app = makeApp(); const s = (await (await app.request("/api/snippets", json({ html: "

x

" }))).json()) as any; diff --git a/test/storeContract.ts b/test/storeContract.ts index fd881d9..615bb21 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -483,6 +483,26 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.equal(onSurface?.postId, surface?.id); assert.equal(onSurface?.postTitle, "Sketch"); + const anchored = await store.createComment({ + sessionId: session.id, + postId: surface?.id, + author: "user", + text: "spot", + anchor: { + kind: "point", + surfaceIndex: 0, + surfaceId: surface?.surfaces[0].id, + surfaceKind: "html", + postVersion: 1, + x: 0.2, + y: 0.8, + }, + }); + assert.deepEqual( + (await store.listComments({ postId: surface?.id ?? "" })).at(-1)?.anchor, + anchored?.anchor, + ); + // a session-level comment, and one pointing at a surface that doesn't exist const onSession = await store.createComment({ sessionId: session.id, @@ -501,6 +521,24 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.equal(ghost?.postId, null); }); + contract("removes comments by id", async (store) => { + const session = await store.createSession({ agent: "pi" }); + const kept = await store.createComment({ sessionId: session.id, author: "user", text: "keep" }); + const gone = await store.createComment({ + sessionId: session.id, + author: "user", + text: "delete", + }); + assert.ok(kept && gone); + + assert.equal(await store.removeComment("missing"), null); + assert.equal((await store.removeComment(gone.id))?.text, "delete"); + assert.deepEqual( + (await store.listComments({ sessionId: session.id })).map((c) => c.text), + ["keep"], + ); + }); + contract("comment seq is strictly monotonic, even across deletes", async (store) => { const first = await store.createSession({ agent: "a" }); const c1 = await store.createComment({ sessionId: first.id, author: "user", text: "1" }); diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index dfa5676..087d237 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -16,6 +16,7 @@ import { isReadonly, relTime, sessionLabel, + type CommentAnchor, type ImageSurface as ImageSurfaceData, type JsonSurface as JsonSurfaceData, type Post, @@ -23,13 +24,14 @@ import { postLink, postImageLink, } from "./api.ts"; -import { CommentIcon, ImageIcon, LinkIcon, OpenIcon, TrashIcon } from "./icons.tsx"; +import { CommentIcon, ImageIcon, LinkIcon, OpenIcon, PinIcon, TrashIcon } from "./icons.tsx"; import { ImageSurface } from "./ImageSurface.tsx"; import { JsonSurface } from "./JsonSurface.tsx"; import { activeTheme, resolvedMode } from "./theme.ts"; import { TraceSurface } from "./TraceSurface.tsx"; import { comments, + deleteComment, focusPost, scrollTarget, sendComment, @@ -91,6 +93,42 @@ let deepLinkScrolling = false; // Iframe heights resolve asynchronously (postMessage resize), so a single // scrollIntoView fires before the layout settles and the target drifts. // Returns a cancel function so the caller can abort on cleanup. +function anchorPoint(anchor: CommentAnchor | undefined): { x: number; y: number } | null { + if (!anchor || anchor.kind === "lineRange") return null; + return { x: anchor.x, y: anchor.y }; +} + +function anchorLabel(anchor: CommentAnchor | undefined): string | null { + if (!anchor) return null; + const where = `surface ${anchor.surfaceIndex + 1}`; + if (anchor.kind === "lineRange") return `${where} · lines ${anchor.startLine}-${anchor.endLine}`; + return `${where} · v${anchor.postVersion}`; +} + +function authorLabel(comment: Pick): string { + return comment.author === "user" ? "you" : comment.author; +} + +function avatarInitials(author: string): string { + const label = author === "user" ? "you" : author; + return ( + label + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((s) => s[0]?.toUpperCase() ?? "") + .join("") || "?" + ); +} + +function AvatarPin(props: { author: string }) { + return ( + + ); +} + function pollScrollIntoView(el: HTMLElement, postId: string): () => void { // If the card is already near the top of the viewport, no polling needed — // skip straight to focusPost so the app behaves identically to a load @@ -142,8 +180,25 @@ export function Card(props: { post: Post; standalone?: boolean }) { // Absolute surface index -> its sandboxed-surface iframe. Lets the version // dropdown rebuild each `/s/:id?part=N` src across every surface with a frame. const surfaceFrames = new Map(); + const [annotating, setAnnotating] = createSignal(false); + const [anchorDraft, setAnchorDraft] = createSignal(null); let stopPoll: (() => void) | undefined; + const anchoredComments = (surfaceIndex: number) => + comments().filter((c) => c.postId === props.post.id && c.anchor?.surfaceIndex === surfaceIndex); + + const sendPinnedComment = async (text: string) => { + const anchor = anchorDraft(); + if (!anchor) return "place a pin first"; + const error = await sendComment( + { surface: props.post.id, text, author: "user", anchor }, + props.post.id, + text, + ); + if (error === null) setAnchorDraft(null); + return error; + }; + // React to scrollTarget changes — start the polling scroll when this card // becomes the target. createEffect tracks scrollTarget(); onMount covers // the initial render (card ref isn't assigned when the effect first runs). @@ -234,47 +289,85 @@ export function Card(props: { post: Post; standalone?: boolean }) { iframe src changes only when the version, the active theme, or the resolved light/dark mode does, so unrelated refetches never reload it. */} - {(surface, i) => ( - - Can’t show this surface — refresh sideshow to update the viewer. - - } - > - - - - - - - - - - - - - - )} + > + + + + + + + + + + + + + +
+ {(c) => } + + {(anchor) => ( + setAnchorDraft(null)} + /> + )} + +
+ + + + + ); + }}
+ + + +
{props.comment.text}
+ + + )} + + ); +} + +function AnchoredComposer(props: { + anchor: CommentAnchor; + send: (text: string) => Promise; + onCancel: () => void; +}) { + let input!: HTMLInputElement; + const point = () => anchorPoint(props.anchor); + const submit = async () => { + const text = input.value.trim(); + if (!text) return; + input.value = ""; + const error = await props.send(text); + if (error !== null) { + if (!input.value) input.value = text; + input.focus(); + toast(`Couldn't post that comment — ${error}. It's back in the box.`); + } + }; + onMount(() => input.focus()); + return ( + + {(pt) => ( +
0.62 }} + style={{ left: `${pt.x * 100}%`, top: `${pt.y * 100}%` }} + > + +
+
+ {avatarInitials("user")} + you + {anchorLabel(props.anchor)} +
+
+ (input = el)} + placeholder="Comment on this spot…" + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + else if (e.key === "Escape" && !input.value) props.onCancel(); + }} + /> + + +
+
+
+ )} +
+ ); +} + function Thread(props: { postId: string | null; placeholder: string; @@ -385,7 +583,7 @@ function Thread(props: { actions?: (startReply: () => void) => JSX.Element; }) { const [replying, setReplying] = createSignal(false); - const list = () => comments().filter((c) => c.postId === props.postId); + const list = () => comments().filter((c) => c.postId === props.postId && !c.anchor); return (
@@ -423,7 +621,9 @@ function Thread(props: { // for an agent to act on the comment when handed it directly. function pasteBlock(c: ViewComment): string { if (c.postId) { - return `sideshow comment on “${c.postTitle ?? "a post"}” (post ${c.postId}):\n“${c.text}”`; + const anchor = anchorLabel(c.anchor); + const where = anchor ? ` at ${anchor}` : ""; + return `sideshow comment on “${c.postTitle ?? "a post"}” (post ${c.postId})${where}:\n“${c.text}”`; } const s = sessions.find((x) => x.id === c.sessionId); return `sideshow comment, session “${s ? sessionLabel(s) : c.sessionId}”:\n“${c.text}”`; @@ -446,6 +646,9 @@ function CommentRow(props: { comment: ViewComment }) { data-cid={props.comment.id} > {props.comment.author === "user" ? "you" : props.comment.author} + + {(label) => {label}} + {/* Plain comment text rendered as a Solid text node — escapes by construction (the invariant's option-(b) for data), so no iframe is needed. `white-space: pre-wrap` (in styles.css) keeps the author's diff --git a/viewer/src/api.ts b/viewer/src/api.ts index ecccf47..d18c63b 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -1,6 +1,7 @@ // Thin client over the REST API, typed against the server's data model. import type { Comment, + CommentAnchor, CodeSurface, DiffSurface, HtmlSurface, @@ -19,6 +20,7 @@ import { host } from "./host.ts"; export type { Comment, + CommentAnchor, CodeSurface, DiffSurface, HtmlSurface, diff --git a/viewer/src/icons.tsx b/viewer/src/icons.tsx index 8f4522f..9364dd0 100644 --- a/viewer/src/icons.tsx +++ b/viewer/src/icons.tsx @@ -39,6 +39,16 @@ export function CommentIcon() { ); } +// lucide: map-pin +export function PinIcon() { + return ( + + + + + ); +} + // lucide: link export function LinkIcon() { return ( diff --git a/viewer/src/state.ts b/viewer/src/state.ts index adb3f7a..48cc6a2 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -380,11 +380,24 @@ let localSeq = 0; // Echo the comment immediately (pending until the POST confirms), and on // failure report the error so the composer can put the text back — a user // message must never be silently lost. Returns the error message, or null. +export async function deleteComment(id: string): Promise { + const prior = commentsState(); + setCommentsInternal((prev) => prev.filter((c) => c.id !== id)); + try { + await api(`/api/comments/${encodeURIComponent(id)}`, { method: "DELETE" }); + return null; + } catch (err) { + setCommentsInternal(prior); + return err instanceof Error && err.message ? err.message : "network error"; + } +} + export async function sendComment( body: Record, postId: string | null, text: string, ): Promise { + const anchor = body.anchor as Comment["anchor"] | undefined; const local: ViewComment = { id: `local-${++localSeq}`, seq: 0, @@ -394,6 +407,7 @@ export async function sendComment( author: "user", text, createdAt: new Date().toISOString(), + ...(anchor && { anchor }), pending: true, }; setCommentsInternal((prev) => [...prev, local]); @@ -465,6 +479,8 @@ export function connect() { const res = await api<{ comments: Comment[] }>(`/api/comments?${query}`); mergeComments(res.comments); } + } else if (e.type === "comment-deleted") { + setCommentsInternal((prev) => prev.filter((c) => c.id !== e.id)); } }; } diff --git a/viewer/src/styles.css b/viewer/src/styles.css index 1e0822b..7206ca0 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -516,6 +516,247 @@ select.vbadge { .card-head .act.icon.del:hover { color: var(--danger); } +.surface-shell { + position: relative; +} +.surface-shell.annotating { + outline: 1px solid var(--accent); + outline-offset: -1px; +} +.surface-pins { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; +} +.anchored-note { + position: absolute; + width: 0; + height: 0; + pointer-events: none; + z-index: 1; +} +.anchored-note.pending { + opacity: 0.65; +} +.anchored-note::before { + content: ""; + position: absolute; + left: -14px; + top: -36px; + width: 288px; + height: 130px; + pointer-events: auto; +} +.anchored-note.left::before { + left: auto; + right: -14px; +} +.avatar-pin { + position: absolute; + left: 0; + top: 0; + transform: translate(-50%, -100%); + display: grid; + place-items: start center; + width: 24px; + height: 30px; + color: var(--accent); + filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.22)); + pointer-events: auto; +} +.avatar-pin::before { + content: ""; + position: absolute; + inset: 0; + background: var(--accent); + clip-path: polygon( + 50% 100%, + 26% 65%, + 16% 52%, + 11% 36%, + 16% 20%, + 29% 7%, + 50% 1%, + 71% 7%, + 84% 20%, + 89% 36%, + 84% 52%, + 74% 65% + ); +} +.avatar-pin span { + position: relative; + display: grid; + place-items: center; + width: 17px; + height: 17px; + margin-top: 4px; + border: 1.5px solid var(--surface); + border-radius: 999px; + color: white; + background: linear-gradient(135deg, var(--accent), var(--border-2)); + font: 700 8px/1 var(--font-sans, system-ui, sans-serif); +} +.anchor-card { + position: absolute; + left: 14px; + top: -24px; + width: 250px; + color: var(--text); + background: var(--surface); + border: 0.5px solid var(--border-2); + border-radius: 12px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); + overflow: hidden; + pointer-events: none; + opacity: 0; + transform: translateY(5px) scale(0.97); + transform-origin: 0 12px; + transition: + opacity 0.14s var(--ease-out-strong) 0.28s, + transform 0.16s var(--ease-out-strong) 0.28s; +} +.anchored-note:hover .anchor-card, +.anchored-note:focus-within .anchor-card, +.anchored-note.composing .anchor-card { + pointer-events: auto; + opacity: 1; + transform: translateY(0) scale(1); + transition-delay: 0s; +} +.anchored-note.left .anchor-card { + right: 14px; + left: auto; + transform-origin: 100% 12px; +} +.anchor-head { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 10px 4px; +} +.anchor-head .avatar { + flex: none; + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 999px; + color: white; + background: linear-gradient(135deg, var(--accent), var(--border-2)); + font: 700 10px/1 var(--font-sans, system-ui, sans-serif); +} +.anchor-head .who { + min-width: 0; + font-weight: 600; + font-size: 12px; + color: var(--text); +} +.anchor-head .when, +.anchor-meta { + margin-left: auto; + white-space: nowrap; + font-size: 11px; + color: var(--faint); +} +.anchor-del { + flex: none; + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + margin-left: -2px; + color: var(--faint); + background: none; + border: none; + border-radius: 5px; + cursor: pointer; + opacity: 0; +} +.anchor-card:hover .anchor-del, +.anchor-card:focus-within .anchor-del { + opacity: 1; +} +.anchor-del svg { + width: 13px; + height: 13px; +} +.anchor-del:hover { + color: var(--danger); + background: var(--hover); +} +.anchor-text { + padding: 0 10px 9px 42px; + font-size: 12.5px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.anchor-compose { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + padding: 4px 10px 10px 42px; +} +.anchor-compose input { + grid-column: 1 / -1; + min-width: 0; + font: 12.5px/1.4 inherit; + color: var(--text); + background: var(--bg); + border: 0.5px solid var(--accent); + border-radius: 7px; + padding: 6px 8px; + outline: none; +} +.anchor-compose button { + font: 12px inherit; + color: var(--muted); + background: none; + border: 0.5px solid var(--border-2); + border-radius: 7px; + padding: 0 8px; + cursor: pointer; +} +.anchor-compose button:hover { + color: var(--text); + background: var(--hover); +} +.anchor-compose .ghost { + border: none; + color: var(--faint); +} +.surface-capture { + position: absolute; + inset: 0; + z-index: 4; + font: 12.5px inherit; + color: var(--accent); + background: color-mix(in srgb, var(--accent-bg) 12%, transparent); + border: none; + cursor: crosshair; +} +.surface-capture::after { + content: attr(data-tip); + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: var(--accent); + background: var(--surface); + border: 0.5px solid var(--border-2); + border-radius: 999px; + padding: 5px 10px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14); + opacity: 0; + transition: opacity 0.12s; + pointer-events: none; +} +.surface-capture:hover::after, +.surface-capture:focus-visible::after { + opacity: 1; +} /* Every surface that becomes HTML (html, markdown, code, diff, terminal, mermaid) renders as a sandboxed iframe pointed at /s/:id. These rules only size the frame and draw the card separator; the in-frame resize bridge sets the real @@ -715,6 +956,26 @@ iframe { .cmt.user .who { color: var(--accent); } +.cmt.flash { + animation: cmt-flash 1.1s ease-out; +} +@keyframes cmt-flash { + 0% { + background: var(--accent-bg); + } + 100% { + background: transparent; + } +} +.anchor-chip { + flex: none; + align-self: center; + font: 11px/1.5 var(--font-mono, ui-monospace, monospace); + color: var(--accent); + background: var(--accent-bg); + border-radius: 999px; + padding: 0 7px; +} /* Comment text is plain text rendered as a Solid text node (escapes by construction — no iframe), so it lives in the trusted DOM. flex:1 takes the row's free space; pre-wrap keeps the author's line breaks. */ @@ -848,6 +1109,10 @@ iframe { color: var(--text); background: var(--hover); } +.card-actions .act.active { + color: var(--accent); + background: var(--accent-bg); +} .card-actions .act.icon { width: 30px; padding: 0;