diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfc3363..57a5eb7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A reply draft typed into one comment thread no longer appears in every other thread. Drafts are now scoped to the thread they were written in: switching threads (with `n`/`p` or the prev/next buttons) shows each thread's own draft, an untouched thread stays empty, and returning to a thread restores the draft you left there. Drafts clear on submit and when you navigate to another page. - Comments from the previous page no longer briefly reappear after navigating to a page that shows none (e.g. Home): a now-superseded comment fetch that resolves after you've navigated away is dropped instead of repopulating the just-cleared list. Resolving or reopening a comment while a navigation is in flight likewise no longer writes the change into the next page's comment view. - Accessibility: the active navigation link and the active "On this page" outline entry now expose `aria-current`, so screen readers announce which page and heading you're on (previously conveyed by color alone); copying a comment's share link is announced via a polite live region rather than only swapping the button icon; and the desktop navigation landmark now carries an accessible name ("Documentation"), distinct from the breadcrumb, table-of-contents, and mobile-navigation landmarks. +- Inline-comment threads are now reachable on narrow windows and phones. Below the width where the right-margin comment column is hidden, tapping a highlighted passage opens its thread in a popover anchored to the highlight (with replies, resolve, and delete), and selecting text → "Add comment" opens the draft box there too — previously both silently did nothing because the only thread surface was the hidden margin column. Escape or tapping away dismisses it; `n`/`p` navigation and tapping another highlight move the popover to that comment. ## [0.1.26] - 2026-06-21 diff --git a/packages/viewer/e2e/comment-narrow-popover.spec.ts b/packages/viewer/e2e/comment-narrow-popover.spec.ts new file mode 100644 index 00000000..5dd4210d --- /dev/null +++ b/packages/viewer/e2e/comment-narrow-popover.spec.ts @@ -0,0 +1,267 @@ +import { test, expect, Page } from "@playwright/test"; +import { resolveDocumentId } from "./comment-helpers"; + +// Narrow viewport: below the 952px comments breakpoint, so the margin aside is +// hidden and the CommentPopover is the only inline-thread surface. +test.use({ viewport: { width: 700, height: 900 } }); +test.describe.configure({ mode: "serial" }); + +// Own page so rows never race comments.spec (homepage) or the deeplink/nav specs. +const DOC_URL = "advanced"; +const DOC_PATH = "/advanced"; + +// Select `text` inside the article and fire the mouseup the viewer listens for. +// Copied from comments.spec.ts. +async function selectText(page: Page, text: string) { + await page.evaluate((targetText) => { + const article = document.querySelector("article"); + if (!article) throw new Error("no article"); + const fullText = article.textContent ?? ""; + const startInDoc = fullText.indexOf(targetText); + if (startInDoc === -1) throw new Error(`text "${targetText}" not found`); + const endInDoc = startInDoc + targetText.length; + const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT); + let offset = 0; + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const len = node.data.length; + if (!startNode && offset + len > startInDoc) { + startNode = node; + startOffset = startInDoc - offset; + } + if (startNode && offset + len >= endInDoc) { + endNode = node; + endOffset = endInDoc - offset; + break; + } + offset += len; + } + if (!startNode || !endNode) throw new Error(`couldn't build range for "${targetText}"`); + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + const rect = range.getBoundingClientRect(); + article.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ); + }, text); +} + +// Seed an inline comment anchored to `quote`. Verified payload shape from +// comment-deeplink.spec.ts: { documentId, body, quote }. +async function seedInline(page: Page, body: string, quote: string): Promise { + const doc = await resolveDocumentId(page, DOC_URL); + return page.evaluate( + async ({ body, quote, doc }) => { + const res = await fetch("/_api/comments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ documentId: doc, body, quote }), + }); + if (!res.ok) throw new Error(`POST /_api/comments -> ${res.status}`); + return (await res.json()).id as string; + }, + { body, quote, doc }, + ); +} + +// Resolve every open comment so each test starts clean. Copied from +// comment-deeplink.spec.ts. +async function resolveAllComments(page: Page) { + const doc = await resolveDocumentId(page, DOC_URL); + const open = await page.evaluate(async (docId) => { + const res = await fetch(`/_api/comments?documentId=${encodeURIComponent(docId)}&status=open`); + return res.json(); + }, doc); + for (const c of open) { + await page.evaluate(async (id) => { + await fetch(`/_api/comments/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "resolved" }), + }); + }, c.id); + } +} + +test.beforeEach(async ({ page }) => { + await page.goto(DOC_PATH); + await resolveAllComments(page); +}); + +// The popover is a labelled group (role="group" aria-label="Comments"), distinct +// from the wide-screen