Implement CE-WP-0004 T01-T05: citation card export (Markdown + HTML)

Per-evidence-item export: click Export → Copy as Markdown / Copy as HTML
writes a portable citation card to the clipboard. Cmd/Ctrl+Shift+C
exports the active evidence as Markdown.

- ADR-0007 locks the Markdown + HTML output formats.
- New shared types: CitationCard, openContextUrl(), resolveSourceLabel().
- Engine renderers under src/engine/rendering/: renderCitationCardMarkdown,
  renderCitationCardHtml — snapshot-tested, escape-safe, BEM classes for HTML.
- src/work/useExportEvidence.ts wires engine + renderers + clipboard.
- EvidenceSidebar gains an Export popover per row + auto-dismissing toast.
- E2E test (tests/integration/citation-card-export-e2e.dom.test.tsx)
  walks PRD scenario steps 10-11 and asserts the clipboard payload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:43:17 +02:00
parent 8607c252c4
commit 8632f7b04a
19 changed files with 1617 additions and 35 deletions

View File

@@ -27,6 +27,7 @@ import {
export * from "./events";
export * from "./repos";
export * from "./services";
export * from "./rendering";
export {
SNAPSHOT_VERSION,
attachPersister,

View File

@@ -0,0 +1,172 @@
/**
* HTML citation card renderer tests (CE-WP-0004-T03).
*
* Snapshots lock the output format defined in
* `docs/decisions/ADR-0007-citation-card-format.md`. Class names are
* part of the public contract — renaming any of them requires updating
* both this test and the ADR.
*/
import { describe, expect, it } from "vitest";
import type { Annotation } from "@shared/annotation";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
import { renderCitationCardHtml } from "./html";
const DOC_ID = "doc_2024-order" as DocumentId;
const REP_ID = "rep_2024-order" as RepresentationId;
const ANN_ID = "ann_para3" as AnnotationId;
const EV_ID = "ev_para3" as EvidenceItemId;
function makeDoc(overrides: Partial<Document> = {}): Document {
return {
id: DOC_ID,
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
return {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
quote: "Die Frist endet am 31. März 2024.",
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
return {
id: EV_ID,
annotationIds: [ANN_ID],
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
describe("renderCitationCardHtml()", () => {
it("renders the full aside with quote, attribution, and commentary", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&amp;annotation=ann_para3">Open source</a></p>
<div class="citation-card__commentary">Deadline clause for the buyer.</div>
</aside>
"
`);
});
it("omits the commentary div when none is set", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&amp;annotation=ann_para3">Open source</a></p>
</aside>
"
`);
});
it("converts \\n inside the quote to <br> for rich-text paste", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn({ quote: "Line one.\nLine two." }),
});
expect(out).toContain(
'<blockquote class="citation-card__quote">Line one.<br>Line two.</blockquote>',
);
});
it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({
commentary: `Notes: <script>alert("xss")</script> & 'untrusted'`,
}),
document: makeDoc({ title: `Order "draft" & more` }),
annotation: makeAnn({ quote: "<<value>> & 'inner'" }),
});
// Quote escaping
expect(out).toContain(
"<blockquote class=\"citation-card__quote\">&lt;&lt;value&gt;&gt; &amp; &#39;inner&#39;</blockquote>",
);
// Source label escaping
expect(out).toContain(
"<cite class=\"citation-card__source\">Order &quot;draft&quot; &amp; more</cite>",
);
// Commentary escaping — and especially: no raw <script>
expect(out).not.toContain("<script>");
expect(out).toContain(
"<div class=\"citation-card__commentary\">Notes: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; &amp; &#39;untrusted&#39;</div>",
);
});
it("falls back to metadata.filename when document.title is missing", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: {
id: DOC_ID,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
metadata: { filename: "order-2024.pdf" },
},
annotation: makeAnn(),
});
expect(out).toContain(
'<cite class="citation-card__source">order-2024.pdf</cite>',
);
});
it("drops the · separator and link when openContextUrlOverride is empty", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "",
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite></p>
</aside>
"
`);
});
it("does not emit inline style attributes (host page controls CSS)", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({ commentary: "Some commentary." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).not.toMatch(/style=/);
});
});

View File

@@ -0,0 +1,92 @@
/**
* HTML citation card renderer.
*
* Implements CE-WP-0004-T03. Output format and rules are locked in
* `docs/decisions/ADR-0007-citation-card-format.md`.
*
* The output is a single `<aside class="citation-card">` element with no
* inline styles — host pages provide the CSS. Commentary is treated as
* plain text; no Markdown or raw HTML passthrough.
*/
import type { Annotation } from "@shared/annotation";
import { resolveSourceLabel } from "@shared/citation-card-source";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import { openContextUrl } from "@shared/open-context-url";
export interface RenderCitationCardHtmlInput {
readonly evidenceItem: EvidenceItem;
readonly document: Document;
readonly annotation: Annotation;
/**
* Override the deep-link URL — same semantics as
* `renderCitationCardMarkdown` (host pages with a non-default mount
* prefix). Defaults to `openContextUrl({…})`.
*/
readonly openContextUrlOverride?: string;
}
const HTML_ENTITIES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => HTML_ENTITIES[c]!);
}
/**
* Convert source line breaks to `<br>` inside the blockquote so a
* multi-line quote stays multi-line in rich-text paste targets. Each
* line is HTML-escaped first.
*/
function escapeQuoteWithLineBreaks(quote: string): string {
return quote.split("\n").map(escapeHtml).join("<br>");
}
export function renderCitationCardHtml({
evidenceItem,
document,
annotation,
openContextUrlOverride,
}: RenderCitationCardHtmlInput): string {
const quote = annotation.quote ?? "";
const sourceLabel = resolveSourceLabel(document);
const url =
openContextUrlOverride !== undefined
? openContextUrlOverride
: openContextUrl({
documentId: document.id,
annotationId: annotation.id,
});
const quoteHtml = escapeQuoteWithLineBreaks(quote);
const sourceHtml = escapeHtml(sourceLabel);
const linkSegment =
url.length > 0
? ` · <a class="citation-card__link" href="${escapeHtml(url)}">Open source</a>`
: "";
const attributionHtml = `<cite class="citation-card__source">${sourceHtml}</cite>${linkSegment}`;
const lines = [
'<aside class="citation-card">',
` <blockquote class="citation-card__quote">${quoteHtml}</blockquote>`,
` <p class="citation-card__attribution">${attributionHtml}</p>`,
];
if (evidenceItem.commentary) {
const commentaryHtml = escapeHtml(evidenceItem.commentary);
lines.push(
` <div class="citation-card__commentary">${commentaryHtml}</div>`,
);
}
lines.push("</aside>");
return lines.join("\n") + "\n";
}

View File

@@ -0,0 +1,8 @@
export {
renderCitationCardMarkdown,
type RenderCitationCardMarkdownInput,
} from "./markdown";
export {
renderCitationCardHtml,
type RenderCitationCardHtmlInput,
} from "./html";

View File

@@ -0,0 +1,194 @@
/**
* Markdown citation card renderer tests (CE-WP-0004-T02).
*
* Snapshots lock the output format defined in
* `docs/decisions/ADR-0007-citation-card-format.md`. If a snapshot
* intentionally changes, update ADR-0007 in the same commit so the
* written contract and the runtime stay in sync.
*/
import { describe, expect, it } from "vitest";
import type { Annotation } from "@shared/annotation";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
import { renderCitationCardMarkdown } from "./markdown";
const DOC_ID = "doc_2024-order" as DocumentId;
const REP_ID = "rep_2024-order" as RepresentationId;
const ANN_ID = "ann_para3" as AnnotationId;
const EV_ID = "ev_para3" as EvidenceItemId;
function makeDoc(overrides: Partial<Document> = {}): Document {
return {
id: DOC_ID,
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
return {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
quote: "Die Frist endet am 31. März 2024.",
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
return {
id: EV_ID,
annotationIds: [ANN_ID],
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
describe("renderCitationCardMarkdown()", () => {
it("renders quote + attribution + commentary", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
Deadline clause for the buyer.
"
`);
});
it("omits the commentary paragraph when none is set", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("preserves multi-line quotes by prefixing each line with '> '", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn({ quote: "Line one.\nLine two.\nLine three." }),
});
expect(out).toMatchInlineSnapshot(`
"> Line one.
> Line two.
> Line three.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("falls back to metadata.filename when document.title is missing", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: {
id: DOC_ID,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
metadata: { filename: "order-2024.pdf" },
},
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *order-2024.pdf* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("escapes * and _ in the source label to keep italics intact", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc({ title: "Order *2024_v2*" }),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order \\*2024\\_v2\\** · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("omits the open-context link when override is empty", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "",
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024*
"
`);
});
it("honours an openContextUrlOverride for mounted-prefix hosts", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "https://citations.example.test/v?d=foo&a=bar",
});
expect(out).toContain(
"[Open source](https://citations.example.test/v?d=foo&a=bar)",
);
});
it("renders an empty blockquote line when the annotation has no quote", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
});
expect(out).toMatchInlineSnapshot(`
">
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
});

View File

@@ -0,0 +1,68 @@
/**
* Markdown citation card renderer.
*
* Implements CE-WP-0004-T02. Output format and rules are locked in
* `docs/decisions/ADR-0007-citation-card-format.md`.
*
* Inputs are passed by value (not looked up from a service) so this
* renderer can be unit-tested without an engine and reused by future
* batch-export jobs.
*/
import type { Annotation } from "@shared/annotation";
import { resolveSourceLabel } from "@shared/citation-card-source";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import { openContextUrl } from "@shared/open-context-url";
export interface RenderCitationCardMarkdownInput {
readonly evidenceItem: EvidenceItem;
readonly document: Document;
readonly annotation: Annotation;
/**
* Override the deep-link URL — useful for hosts that mount the viewer
* under a non-default prefix. Defaults to `openContextUrl({…})`.
*/
readonly openContextUrlOverride?: string;
}
/**
* Escape `*` and `_` inside the source label so it doesn't accidentally
* break out of the surrounding `*…*` italic span. No other escaping is
* performed — citations should reproduce the source text verbatim.
*/
function escapeSourceLabel(label: string): string {
return label.replace(/[*_]/g, (m) => `\\${m}`);
}
export function renderCitationCardMarkdown({
evidenceItem,
document,
annotation,
openContextUrlOverride,
}: RenderCitationCardMarkdownInput): string {
const quote = annotation.quote ?? "";
const sourceLabel = resolveSourceLabel(document);
const url =
openContextUrlOverride !== undefined
? openContextUrlOverride
: openContextUrl({
documentId: document.id,
annotationId: annotation.id,
});
const quoteLines = quote.length === 0 ? [""] : quote.split("\n");
// Trailing whitespace on a blockquote line can be misread as a hard
// line break (`<br>`) by some Markdown engines; trimEnd keeps an
// empty line as bare `>`.
const quoteBlock = quoteLines.map((line) => `> ${line}`.trimEnd()).join("\n");
const attributionLink = url.length > 0 ? ` · [Open source](${url})` : "";
const attribution = `— *${escapeSourceLabel(sourceLabel)}*${attributionLink}`;
const sections = [quoteBlock, attribution];
if (evidenceItem.commentary) sections.push(evidenceItem.commentary);
return sections.join("\n\n") + "\n";
}

View File

@@ -0,0 +1,75 @@
/**
* Unit tests for `resolveSourceLabel()`. Locks the precedence order
* described in workplan CE-WP-0004-T02:
* title → metadata.filename → uri → id.
*/
import { describe, expect, it } from "vitest";
import { resolveSourceLabel } from "./citation-card-source";
import type { Document } from "./document";
import type { DocumentId } from "./ids";
function makeDoc(partial: Partial<Document>): Document {
return {
id: "doc_test" as DocumentId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...partial,
};
}
describe("resolveSourceLabel()", () => {
it("prefers document.title when set", () => {
expect(
resolveSourceLabel(
makeDoc({
title: "Order from 2024",
uri: "https://example.test/o.pdf",
metadata: { filename: "fallback.pdf" },
}),
),
).toBe("Order from 2024");
});
it("falls back to metadata.filename when title is missing", () => {
expect(
resolveSourceLabel(
makeDoc({
uri: "https://example.test/o.pdf",
metadata: { filename: "fallback.pdf" },
}),
),
).toBe("fallback.pdf");
});
it("falls back to uri when title and filename are missing", () => {
expect(
resolveSourceLabel(makeDoc({ uri: "https://example.test/o.pdf" })),
).toBe("https://example.test/o.pdf");
});
it("falls back to id when everything else is missing", () => {
expect(resolveSourceLabel(makeDoc({}))).toBe("doc_test");
});
it("treats whitespace-only strings as empty", () => {
expect(
resolveSourceLabel(
makeDoc({ title: " ", uri: "https://example.test/o.pdf" }),
),
).toBe("https://example.test/o.pdf");
});
it("ignores non-string metadata.filename", () => {
expect(
resolveSourceLabel(
makeDoc({
metadata: { filename: 42 },
uri: "https://example.test/o.pdf",
}),
),
).toBe("https://example.test/o.pdf");
});
});

View File

@@ -0,0 +1,26 @@
/**
* Source-label resolution for citation cards.
*
* Per workplan CE-WP-0004-T02: the label rendered after the quote is
* document.title → document.metadata.filename → document.uri → document.id
* — the first non-empty value wins. Renderers must call this helper so
* the two output formats (Markdown, HTML) stay consistent.
*/
import type { Document } from "./document";
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveSourceLabel(document: Document): string {
const title = nonEmptyString(document.title);
if (title) return title;
const filename = nonEmptyString(document.metadata?.["filename"]);
if (filename) return filename;
const uri = nonEmptyString(document.uri);
if (uri) return uri;
return document.id;
}

View File

@@ -0,0 +1,35 @@
/**
* CitationCard — a presentable rendering of an EvidenceItem.
*
* Implements `wiki/ArchitectureOverview.md` §4.7. Stored or transient,
* always derived from an `EvidenceItem` + its `Document` + the first
* referenced `Annotation`. The renderers in `engine/rendering/` produce
* the string forms (`format: "markdown" | "html"`); the
* `"web-component"` format is reserved for a later workplan (see §14.2).
*
* `id` and `evidenceItemId` use the branded ID types from `./ids` so they
* cannot be swapped at compile time, even though `ArchitectureOverview.md`
* §4.7 shows them as bare `string`s. The brand is invisible at runtime.
*/
import type { CitationCardId, EvidenceItemId } from "./ids";
export type CitationCardFormat = "html" | "markdown" | "web-component";
export interface CitationCard {
readonly id: CitationCardId;
readonly evidenceItemId: EvidenceItemId;
/** Verbatim quote rendered into the card body. */
readonly quote: string;
/**
* Human-readable source label. Resolution order is
* `document.title` → `filename from metadata` → `document.uri` →
* the document id. Renderers should call `resolveSourceLabel()` from
* `./citation-card-source.ts` rather than computing this inline.
*/
readonly sourceLabel: string;
readonly commentary?: string;
/** Deep link to reopen the source context — see `./open-context-url.ts`. */
readonly openContextUrl?: string;
readonly format: CitationCardFormat;
}

View File

@@ -5,4 +5,7 @@ export * from "./annotation";
export * from "./evidence";
export * from "./evidence-link";
export * from "./evidence-set";
export * from "./citation-card";
export * from "./citation-card-source";
export * from "./open-context-url";
export { normalize, NORMALIZE_VERSION } from "./text/normalize";

View File

@@ -0,0 +1,32 @@
/**
* Unit tests for `openContextUrl()`. Locks the shape from
* `wiki/ArchitectureOverview.md` §14.3.
*/
import { describe, expect, it } from "vitest";
import type { AnnotationId, DocumentId } from "./ids";
import { openContextUrl } from "./open-context-url";
const DOC = "doc_abc-123" as DocumentId;
const ANN = "ann_def-456" as AnnotationId;
describe("openContextUrl()", () => {
it("produces the canonical /viewer?document=…&annotation=… shape", () => {
expect(openContextUrl({ documentId: DOC, annotationId: ANN })).toBe(
"/viewer?document=doc_abc-123&annotation=ann_def-456",
);
});
it("percent-encodes reserved characters in ids", () => {
const ugly = "doc with spaces&=#" as DocumentId;
const url = openContextUrl({ documentId: ugly, annotationId: ANN });
expect(url).toBe(
"/viewer?document=doc%20with%20spaces%26%3D%23&annotation=ann_def-456",
);
// round-trip
const params = new URL(url, "https://example.test").searchParams;
expect(params.get("document")).toBe("doc with spaces&=#");
expect(params.get("annotation")).toBe("ann_def-456");
});
});

View File

@@ -0,0 +1,36 @@
/**
* Open-context URL — the canonical deep link that lets a citation card
* reopen the source context that backs it.
*
* Implements `wiki/ArchitectureOverview.md` §14.3. The URL shape is:
*
* /viewer?document=<documentId>&annotation=<annotationId>
*
* Both ids are mandatory. The annotation alone is insufficient because
* the router needs the document id to mount the right viewer adapter
* before resolving the annotation's selectors.
*
* This convention is stable across persistence modes — when the
* in-memory engine is replaced by a real backend (ADR-0005), the same
* URL shape is expected to still resolve.
*/
import type { AnnotationId, DocumentId } from "./ids";
export interface OpenContextUrlInput {
readonly documentId: DocumentId;
readonly annotationId: AnnotationId;
}
/**
* Build the deep link for a given (document, annotation) pair.
*
* Query-string values are percent-encoded via `encodeURIComponent` so that
* any future id scheme containing reserved characters (`&`, `=`, `#`) still
* round-trips.
*/
export function openContextUrl(input: OpenContextUrlInput): string {
const docPart = encodeURIComponent(input.documentId);
const annPart = encodeURIComponent(input.annotationId);
return `/viewer?document=${docPart}&annotation=${annPart}`;
}

View File

@@ -0,0 +1,210 @@
/**
* EvidenceSidebar export-flow tests (CE-WP-0004-T04).
*
* Covers:
* - Export popover opens on click, exposes Markdown + HTML options.
* - Copy as Markdown writes the rendered card to navigator.clipboard
* and shows a success toast.
* - Copy as HTML writes the HTML card.
* - Clipboard failure surfaces an error toast.
* - Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
*
* Uses a real engine (not a mock) so the renderer + service plumbing is
* exercised end-to-end up to the clipboard boundary.
*/
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEngine, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import { newId } from "@shared/ids";
import { EngineProvider, EvidenceSidebar } from "./index";
// happy-dom ships a real `navigator.clipboard.writeText` that stashes
// the text into an internal Blob. We spy on the prototype method so
// every test's clicks route through our mock without fighting the
// Navigator class's `#clipboard` private field. `mockImplementation`
// per test swaps in the success or failure behaviour.
let writeText: ReturnType<typeof vi.spyOn> &
((text: string) => Promise<void>);
let lastEngine: Engine | null = null;
let activeDocumentId: DocumentId | null = null;
function seedEngine(): { engine: Engine; documentId: DocumentId } {
const engine = createEngine();
const now = "2026-05-25T00:00:00.000Z";
const { document } = engine.documents.register({
document: {
id: newId("document"),
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
fingerprint: "test-fingerprint",
createdAt: now,
updatedAt: now,
},
representation: {
id: newId("representation"),
documentId: newId("document"),
representationType: "pdf-text",
contentHash: "test-fingerprint",
canonicalText: "Die Frist endet am 31. März 2024.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 34, pageLength: 34 }],
generatedAt: now,
},
});
const annotation = engine.annotations.create({
documentId: document.id,
selectors: [
{ type: "TextQuoteSelector", exact: "Die Frist endet am 31. März 2024." },
],
quote: "Die Frist endet am 31. März 2024.",
});
engine.evidence.create({
annotationIds: [annotation.id],
commentary: "Deadline clause for the buyer.",
});
return { engine, documentId: document.id };
}
function Harness({ engine, documentId }: { engine: Engine; documentId: DocumentId }) {
lastEngine = engine;
activeDocumentId = documentId;
return (
<EngineProvider engine={engine}>
<ActiveDocumentSetter documentId={documentId}>
<EvidenceSidebar />
</ActiveDocumentSetter>
</EngineProvider>
);
}
import { useEffect } from "react";
import { useActiveDocumentId } from "./EngineContext";
function ActiveDocumentSetter({
documentId,
children,
}: {
documentId: DocumentId;
children: React.ReactNode;
}) {
const { setId } = useActiveDocumentId();
useEffect(() => {
setId(documentId);
}, [documentId, setId]);
return <>{children}</>;
}
function installClipboard(impl: (text: string) => Promise<void>) {
writeText = vi.fn(impl) as unknown as typeof writeText;
// happy-dom recreates its Clipboard prototype across the
// beforeEach → render boundary in some test passes, so patches
// applied earlier (e.g. in beforeEach) get silently discarded.
// Patching the *current* prototype right before the action under
// test (i.e. after render) is reliable.
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
describe("EvidenceSidebar — export flow (CE-WP-0004-T04)", () => {
beforeEach(() => {
lastEngine = null;
activeDocumentId = null;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders an Export toggle for each evidence item", async () => {
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
const toggle = await screen.findByLabelText("Export evidence item");
expect(toggle).toBeTruthy();
});
it("opens the menu and copies Markdown to the clipboard", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain("> Die Frist endet am 31. März 2024.");
expect(written).toContain("— *Order from 14 Mar 2024*");
expect(written).toContain("[Open source](/viewer?document=");
expect(written).toContain("Deadline clause for the buyer.");
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("success");
expect(toast.textContent).toContain("Copied as Markdown");
});
it("copies HTML when the HTML menu item is clicked", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as HTML" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain('<aside class="citation-card">');
expect(written).toContain('<blockquote class="citation-card__quote">');
const toast = await screen.findByTestId("export-toast");
expect(toast.textContent).toContain("Copied as HTML");
});
it("surfaces a clipboard write failure via the error toast", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => {
throw new Error("denied");
});
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("error");
expect(toast.textContent).toMatch(/clipboard write was rejected|Copy failed/);
});
it("Cmd+Shift+C exports the active evidence as Markdown", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
// Activate the item first by clicking the card body. The card body is
// the button that contains the quote/commentary, not the Export toggle.
const itemButton = await screen.findByText(/Deadline clause for the buyer/);
await user.click(itemButton);
expect(writeText).not.toHaveBeenCalled();
await user.keyboard("{Control>}{Shift>}c{/Shift}{/Control}");
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain("> Die Frist endet am 31. März 2024.");
const toast = await screen.findByTestId("export-toast");
expect(toast.textContent).toContain("Copied as Markdown");
});
});
// Silence "unused" warnings for the test-scope captures we kept for
// debugging — TypeScript would otherwise complain.
void lastEngine;
void activeDocumentId;

View File

@@ -6,12 +6,22 @@
* `EvidenceItemActivated` via the engine, which T08 will translate into a
* scroll-to-passage in the viewer.
*
* T06 scope: read-only display + activation event. Item creation lives in
* T07; the click-to-reopen integration lives in T08.
* CE-WP-0004-T04 added: a per-item Export popover (Copy as Markdown /
* Copy as HTML), a transient toast confirming the copy, and the
* Cmd/Ctrl+Shift+C keyboard shortcut that exports the currently-active
* evidence as Markdown.
*/
import { useMemo } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import type { EvidenceItem } from "@shared/evidence";
import type { EvidenceItemId } from "@shared/ids";
import {
useActiveDocument,
useEngine,
@@ -19,28 +29,102 @@ import {
useLastActivatedEvidence,
useScrollToAnnotation,
} from "./EngineContext";
import {
useExportEvidence,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
const TOAST_TIMEOUT_MS = 2000;
export interface EvidenceSidebarProps {
onActivate?(item: EvidenceItem): void;
}
interface ToastState {
readonly message: string;
readonly tone: "success" | "error";
/** Bumps on every new toast so timers don't dismiss the *next* toast. */
readonly key: number;
}
function describeError(result: Extract<ExportResult, { ok: false }>): string {
switch (result.reason) {
case "no-annotation":
case "annotation-missing":
return "Cannot export: no source annotation.";
case "document-missing":
return "Cannot export: source document missing.";
case "clipboard-unavailable":
return "Clipboard not available in this browser.";
case "clipboard-write-failed":
return "Copy failed — clipboard write was rejected.";
}
}
function describeSuccess(format: ExportFormat): string {
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
}
export function EvidenceSidebar(props: EvidenceSidebarProps) {
const engine = useEngine();
const { document } = useActiveDocument();
const { scrollTo } = useScrollToAnnotation();
const activeId = useLastActivatedEvidence();
const { exportItem } = useExportEvidence();
// Refresh the list when items are created or updated. The tick values are
// included in the memo deps below so the list re-resolves on each event.
const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated");
const items = useMemo<readonly EvidenceItem[]>(() => {
if (!document) return [];
return engine.evidence.listByDocument(document.id);
// createTick / updateTick are read here purely as memo invalidators.
}, [document, engine, createTick, updateTick]);
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);
const toastKeyRef = useRef(0);
const showToast = useCallback((message: string, tone: "success" | "error") => {
toastKeyRef.current += 1;
const key = toastKeyRef.current;
setToast({ message, tone, key });
}, []);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => {
setToast((current) => (current && current.key === toast.key ? null : current));
}, TOAST_TIMEOUT_MS);
return () => clearTimeout(t);
}, [toast]);
const runExport = useCallback(
async (item: EvidenceItem, format: ExportFormat) => {
const result = await exportItem(item, format);
if (result.ok) showToast(describeSuccess(result.format), "success");
else showToast(describeError(result), "error");
},
[exportItem, showToast],
);
// Cmd/Ctrl+Shift+C: export the active evidence as Markdown.
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: KeyboardEvent) => {
const modifier = e.metaKey || e.ctrlKey;
if (!modifier || !e.shiftKey) return;
if (e.key !== "C" && e.key !== "c") return;
if (!activeId) return;
const item = engine.evidence.get(activeId);
if (!item) return;
e.preventDefault();
void runExport(item, "markdown");
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [activeId, engine, runExport]);
return (
<aside
style={{
@@ -50,6 +134,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
overflow: "auto",
flex: "0 0 320px",
fontFamily: "system-ui, sans-serif",
position: "relative",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Evidence</h2>
@@ -64,42 +149,162 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{items.map((item) => {
const firstAnnotationId = item.annotationIds[0];
const annotation = firstAnnotationId ? engine.annotations.get(firstAnnotationId) : null;
const annotation = firstAnnotationId
? engine.annotations.get(firstAnnotationId)
: null;
const quote = annotation?.quote ?? "(no quote)";
const isActive = activeId === item.id;
const isExportOpen = openExportFor === item.id;
return (
<li key={item.id} style={{ marginBottom: 8 }}>
<button
onClick={() => {
engine.evidence.activate(item.id, "sidebar");
if (firstAnnotationId) scrollTo(firstAnnotationId);
props.onActivate?.(item);
}}
aria-current={isActive ? "true" : undefined}
<div
style={{
display: "block",
width: "100%",
textAlign: "left",
position: "relative",
background: isActive ? "#e8f0ff" : "#fff8d6",
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
padding: 8,
cursor: "pointer",
fontSize: 12,
borderRadius: 2,
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</div>
{item.commentary && (
<div style={{ color: "#333", marginBottom: 4 }}>{item.commentary}</div>
<button
onClick={() => {
engine.evidence.activate(item.id, "sidebar");
if (firstAnnotationId) scrollTo(firstAnnotationId);
props.onActivate?.(item);
}}
aria-current={isActive ? "true" : undefined}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
paddingRight: 80,
cursor: "pointer",
fontSize: 12,
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</div>
{item.commentary && (
<div style={{ color: "#333", marginBottom: 4 }}>
{item.commentary}
</div>
)}
<div style={{ color: "#666", fontSize: 11 }}>
status: {item.status}
</div>
</button>
<button
type="button"
aria-haspopup="menu"
aria-expanded={isExportOpen}
aria-label="Export evidence item"
data-testid={`export-toggle-${item.id}`}
onClick={(e) => {
e.stopPropagation();
setOpenExportFor((current) =>
current === item.id ? null : item.id,
);
}}
style={{
position: "absolute",
top: 6,
right: 6,
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
}}
>
Export
</button>
{isExportOpen && (
<div
role="menu"
data-testid={`export-menu-${item.id}`}
style={{
position: "absolute",
top: 28,
right: 6,
zIndex: 10,
background: "white",
border: "1px solid #888",
borderRadius: 3,
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
padding: 4,
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 160,
}}
>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
setOpenExportFor(null);
await runExport(item, "markdown");
}}
style={menuButtonStyle}
>
Copy as Markdown
</button>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
setOpenExportFor(null);
await runExport(item, "html");
}}
style={menuButtonStyle}
>
Copy as HTML
</button>
</div>
)}
<div style={{ color: "#666", fontSize: 11 }}>status: {item.status}</div>
</button>
</div>
</li>
);
})}
</ul>
{toast && (
<div
role="status"
aria-live="polite"
data-testid="export-toast"
data-tone={toast.tone}
style={{
position: "absolute",
left: 12,
right: 12,
bottom: 12,
padding: 8,
fontSize: 12,
background: toast.tone === "success" ? "#d6f0d6" : "#f9d6d6",
color: toast.tone === "success" ? "#0a5a0a" : "#7a0000",
border: `1px solid ${toast.tone === "success" ? "#0a5a0a" : "#7a0000"}`,
borderRadius: 3,
}}
>
{toast.message}
</div>
)}
</aside>
);
}
const menuButtonStyle: CSSProperties = {
textAlign: "left",
background: "transparent",
border: "none",
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
};

View File

@@ -1,6 +1,12 @@
export { CollectionList } from "./CollectionList";
export { ViewerShell } from "./ViewerShell";
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
export {
useExportEvidence,
type ExportEvidenceApi,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
export { AnnotationToolbar } from "./AnnotationToolbar";
export {
EngineProvider,

View File

@@ -0,0 +1,89 @@
/**
* `useExportEvidence` — wires engine + renderers + clipboard for the
* sidebar export affordance (CE-WP-0004-T04).
*
* Renderers live in `@engine/rendering`; the hook resolves the
* (`EvidenceItem`, `Document`, first `Annotation`) triple from the engine
* for a given item id, renders it, and writes the result to
* `navigator.clipboard`.
*
* Returns `{ ok: true }` on success and `{ ok: false, reason }` on any
* failure — callers (the sidebar toast) decide what to render. Errors
* are swallowed at the boundary instead of throwing so a clipboard
* permission denial doesn't crash the UI tree.
*/
import { useCallback } from "react";
import {
renderCitationCardHtml,
renderCitationCardMarkdown,
} from "@engine/rendering";
import type { EvidenceItem } from "@shared/evidence";
import { useEngine } from "./EngineContext";
export type ExportFormat = "markdown" | "html";
export type ExportResult =
| { readonly ok: true; readonly format: ExportFormat; readonly content: string }
| {
readonly ok: false;
readonly reason:
| "no-annotation"
| "annotation-missing"
| "document-missing"
| "clipboard-unavailable"
| "clipboard-write-failed";
};
export interface ExportEvidenceApi {
/**
* Render the given item as `format` and write it to the clipboard.
* Resolves with the result; rejection never propagates.
*/
exportItem(item: EvidenceItem, format: ExportFormat): Promise<ExportResult>;
}
/**
* Inline export: build the content from item+document+annotation but skip
* the clipboard step. Useful for tests and for surfaces (toasts, future
* "Export as file" dialogs) that want the rendered string.
*/
export function useExportEvidence(): ExportEvidenceApi {
const engine = useEngine();
const exportItem = useCallback<ExportEvidenceApi["exportItem"]>(
async (item, format) => {
const annotationId = item.annotationIds[0];
if (!annotationId) return { ok: false, reason: "no-annotation" };
const annotation = engine.annotations.get(annotationId);
if (!annotation) return { ok: false, reason: "annotation-missing" };
const document = engine.documents.get(annotation.documentId);
if (!document) return { ok: false, reason: "document-missing" };
const content =
format === "markdown"
? renderCitationCardMarkdown({ evidenceItem: item, document, annotation })
: renderCitationCardHtml({ evidenceItem: item, document, annotation });
const clipboard =
typeof navigator !== "undefined" ? navigator.clipboard : undefined;
if (!clipboard || typeof clipboard.writeText !== "function") {
return { ok: false, reason: "clipboard-unavailable" };
}
try {
await clipboard.writeText(content);
return { ok: true, format, content };
} catch {
return { ok: false, reason: "clipboard-write-failed" };
}
},
[engine],
);
return { exportItem };
}