diff --git a/CHANGELOG.md b/CHANGELOG.md index 36241f6..f438b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ This project follows [Keep a Changelog](https://keepachangelog.com/) and the others were invisible and unreachable. The marker now carries a small count, and tapping it lists every note so you can open any of them. A verse with a single note is unchanged. ([#114](https://github.com/kbennett2000/songbird/issues/114)) +- **The multiple-notes list is calmer and easier to read.** That list used to show each note's full + text as one long, run-together scroll. It's now a tidy stack of cards — one clear preview line per + note, with its tags, and obvious gaps between them (in dark mode too). Tap a note to open it in + full. ([#116](https://github.com/kbennett2000/songbird/issues/116)) ## [1.6.0] — 2026-06-09 diff --git a/frontend/src/components/AnnotationsPopover.test.tsx b/frontend/src/components/AnnotationsPopover.test.tsx index 37b5526..1e79841 100644 --- a/frontend/src/components/AnnotationsPopover.test.tsx +++ b/frontend/src/components/AnnotationsPopover.test.tsx @@ -76,24 +76,39 @@ describe("AnnotationsPopover", () => { expect(screen.getByText("grace")).toBeInTheDocument(); }); - it("calls onOpen with the clicked annotation (reader case)", async () => { + it("calls onOpen with the clicked card's annotation (reader case)", async () => { const onOpen = vi.fn(); renderPopover([ann(1), ann(2)], { onOpen }); - const openButtons = screen.getAllByRole("button", { name: "Open →" }); - await userEvent.click(openButtons[1]!); - // Newest-first: row 0 is Note 1 (newer created_at default tie → stable), so assert by call arg id. + // The whole card is the button; its accessible name carries the note's preview. + await userEvent.click(screen.getByRole("button", { name: "Open note: Note 2" })); expect(onOpen).toHaveBeenCalledTimes(1); - expect(onOpen.mock.calls[0]![0]).toMatchObject({ id: expect.any(Number) }); + expect(onOpen.mock.calls[0]![0]).toMatchObject({ id: 2 }); }); - it("falls back to reader deep-links when onOpen is absent (compare case)", () => { + it("makes each card a reader deep-link when onOpen is absent (compare case)", () => { renderPopover([ann(1), ann(2)]); - expect(screen.queryByRole("button", { name: "Open →" })).not.toBeInTheDocument(); - const links = screen.getAllByRole("link", { name: /Open in reader/ }); + expect(screen.queryByRole("button", { name: /Open note/ })).not.toBeInTheDocument(); + const links = screen.getAllByRole("link", { name: /Open note in reader/ }); expect(links).toHaveLength(2); expect(links[0]).toHaveAttribute("href", "/read?book=JOS&chapter=1&verse=1"); }); + it("shows a stripped, truncated preview — never a wall of raw Markdown (#116)", () => { + const long = `# Big Heading\n\n- bullet\n\n${"x".repeat(200)}`; + renderPopover([ann(1, { note_markdown: long })]); + const preview = screen.getByText(/^Big Heading/); + expect(preview.textContent).not.toContain("#"); + expect(preview.textContent).not.toContain("- bullet"); // the "-" Markdown noise is stripped + expect(preview.textContent!.endsWith("…")).toBe(true); + expect(preview.textContent!.length).toBeLessThanOrEqual(121); // notePreview caps at 120 + "…" + expect(screen.queryByText(long)).not.toBeInTheDocument(); // the raw note is NOT dumped verbatim + }); + + it("labels an empty note rather than rendering a blank card", () => { + renderPopover([ann(1, { note_markdown: " " })], { onOpen: vi.fn() }); + expect(screen.getByRole("button", { name: "Open note: (empty note)" })).toBeInTheDocument(); + }); + it("closes on Escape (shared Popover shell)", async () => { const { onClose } = renderPopover([ann(1), ann(2)]); await userEvent.keyboard("{Escape}"); diff --git a/frontend/src/components/AnnotationsPopover.tsx b/frontend/src/components/AnnotationsPopover.tsx index 2805bc8..51c4634 100644 --- a/frontend/src/components/AnnotationsPopover.tsx +++ b/frontend/src/components/AnnotationsPopover.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Link } from "react-router-dom"; import { Popover } from "@/components/Popover"; -import { readerLink } from "@/lib/notes"; +import { notePreview, readerLink } from "@/lib/notes"; import type { ReadAnnotation } from "@/schemas"; interface AnnotationsPopoverProps { @@ -12,8 +12,8 @@ interface AnnotationsPopoverProps { anchor: HTMLElement; onClose: () => void; /** - * Reader case: open this note for editing. When provided, each row gets an "Open" button. - * When omitted (compare case), each row instead deep-links back to the reader (read-only). + * Reader case: open this note for editing. When provided, each card is a button that opens it. + * When omitted (compare case), each card is instead a deep-link back to the reader (read-only). */ onOpen?: (annotation: ReadAnnotation) => void; } @@ -23,11 +23,54 @@ function byNewest(a: ReadAnnotation, b: ReadAnnotation): number { return a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0; } +const CARD_CLASS = + "block w-full text-left rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 hover:bg-gray-50 dark:hover:bg-gray-700"; + +/** The body shared by both card variants: out-of-scope line, one-line preview + chevron, tags. */ +function CardBody({ annotation }: { annotation: ReadAnnotation }): JSX.Element { + const preview = notePreview(annotation.note_markdown); + return ( + <> + {!annotation.in_scope && annotation.scope_translations.length > 0 && ( +
+ Written for {annotation.scope_translations.join(", ")} +
+ )} +- Written for {annotation.scope_translations.join(", ")} -
- )} -- {annotation.note_markdown} -
- {annotation.tags.length > 0 && ( -