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

@@ -0,0 +1,115 @@
# ADR-0007 — Citation card output format (Markdown and HTML)
- Status: accepted
- Date: 2026-05-25
- Workplan: CE-WP-0004-T02 (Markdown renderer) and CE-WP-0004-T03 (HTML renderer)
- Spec refs: `wiki/ArchitectureOverview.md` §4.7, §14.1, §14.2, §14.3
## Context
The MVP scenario ends with a user exporting an evidence item as a portable
citation card. Two formats ship in CE-WP-0004:
- **Markdown** — copied to the clipboard for pasting into notes, emails,
GitHub issues, and so on. Renders well as plain text and as rendered
Markdown.
- **HTML** — copied for pasting into rich-text editors and web pages.
A third format, the `<citation-card>` Web Component from
`ArchitectureOverview.md` §14.2, is out of scope here and lands in a later
workplan. Its visual presentation should be *equivalent* to the HTML form
but is not constrained to be byte-identical.
The two formats need a written contract so that:
1. UI components (T04 sidebar export, future web embeds) can rely on the
exact output structure.
2. Snapshot tests fail loudly if the format drifts.
3. Consumers that style the HTML form know which elements and classes are
stable.
## Decision
### Markdown format (CE-WP-0004-T02)
```markdown
> {quote}
*{sourceLabel}* · [Open source]({openContextUrl})
{commentary}
```
Rules:
- Each `{quote}` line is rendered with the leading `> ` blockquote marker,
preserving line breaks in the source quote. A single-line quote is one
blockquote line; a multi-line quote becomes multiple `> `-prefixed
lines.
- A blank line follows the blockquote.
- The attribution line uses an em dash (`—`, U+2014) followed by a single
space, the italicised source label, a middle dot (`·`, U+00B7) with
surrounding spaces, and the `[Open source]({openContextUrl})` link.
- The middle dot + link segment is **omitted entirely** when no
`openContextUrl` is provided (which is unusual but possible for
evidence items without an annotation).
- A blank line follows the attribution.
- The optional `{commentary}` paragraph is rendered as-is. When absent
the trailing blank line and commentary paragraph are both omitted.
- The output ends with a single trailing newline.
Reserved Markdown characters inside the quote are not escaped — the
intent is to reproduce the source text verbatim. The blockquote prefix
already neutralises the most dangerous reflow problems. The
`{sourceLabel}` is escaped to defuse `*`/`_` only; the link target is
URL-encoded by `openContextUrl()`.
### HTML format (CE-WP-0004-T003)
A single `<aside class="citation-card">` root element with this stable
structure:
```html
<aside class="citation-card">
<blockquote class="citation-card__quote">{escaped quote}</blockquote>
<p class="citation-card__attribution">
<cite class="citation-card__source">{escaped source label}</cite>
<a class="citation-card__link" href="{open context url}">Open source</a>
</p>
<div class="citation-card__commentary">{escaped commentary}</div>
</aside>
```
Rules:
- All user-supplied text is HTML-escaped (`&`, `<`, `>`, `"`, `'`).
- Inline styles are **not** emitted. Host pages provide the CSS.
- The `<a>` and the attribution `·` separator are omitted when no
`openContextUrl` is provided.
- The `<div class="citation-card__commentary">` is omitted when no
commentary is provided.
- Commentary is treated as plain text — no Markdown or raw HTML
passthrough. A future workplan can introduce a sanitiser if rich
commentary is required.
- The output ends with a single trailing newline.
### Class-name contract
The four BEM-style class names — `citation-card`, `citation-card__quote`,
`citation-card__attribution`, `citation-card__source`,
`citation-card__link`, `citation-card__commentary` — are part of the
public contract. They must not be renamed without an ADR superseding
this one.
## Consequences
- Snapshot tests in `src/engine/rendering/*.test.ts` lock these
formats. Intentional changes require updating both the snapshots and
this ADR.
- The Web Component planned for §14.2 will reuse the HTML structure
inside its shadow DOM, so the class names also become the
customisation surface for downstream stylesheets.
- The `openContextUrl` shape from
`wiki/ArchitectureOverview.md` §14.3 is now consumed by two
renderers; changing the URL scheme requires regenerating snapshots
and announcing via a new ADR.

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 };
}

View File

@@ -0,0 +1,215 @@
/**
* CE-WP-0004-T05 — end-to-end test of PRD scenario step 10.
*
* Continues the slice-1 scenario verified by CE-WP-0002-T09:
*
* 1-5 (recap from T09): load app → pick fixture → inject selection →
* save evidence item.
* 10. Click Export → Copy as Markdown on the saved evidence item.
* 11. Read the clipboard; assert it contains the quote text, the
* document title, the commentary, and a URL matching the
* `/viewer?document=...&annotation=...` shape.
*
* The Playwright form mentioned in the workplan is approximated with the
* same happy-dom integration setup the rest of the slice-1 tests use
* (see ADR-0004 for the headless-Chromium limitation that motivates
* this trade-off). The viewer and the PDF ingest pipeline are mocked;
* the clipboard is intercepted by patching `Clipboard.prototype.writeText`.
*/
// @vitest-environment happy-dom
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
interface ViewerProps {
pdfUrl: string;
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
scrollToAnnotationId?: string;
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
}
interface ViewerSnapshot {
pdfUrl: string | null;
onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null;
}
const viewerSnapshot: ViewerSnapshot = {
pdfUrl: null,
onSelectionCaptured: null,
};
vi.mock("@anchor/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (props: ViewerProps) => {
viewerSnapshot.pdfUrl = props.pdfUrl;
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
return <div data-testid="mock-pdf-viewer" />;
};
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
});
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "synthetic-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
kind: "pdf",
text,
page,
rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
};
}
let writeText: ReturnType<typeof vi.fn>;
function installClipboardSpy() {
writeText = vi.fn(async () => undefined);
// happy-dom's Clipboard.writeText lives on the prototype. Patch there
// so the spy intercepts every call without fighting the Navigator
// class's private #clipboard field.
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
async function loadApp() {
const { App } = await import("@app/App");
return render(<App />);
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
beforeEach(() => {
viewerSnapshot.pdfUrl = null;
viewerSnapshot.onSelectionCaptured = null;
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
});
afterEach(() => {
vi.restoreAllMocks();
});
it(
"saves an evidence item, exports it as Markdown, and writes the citation card to the clipboard",
{ timeout: 15000 },
async () => {
const user = userEvent.setup();
await loadApp();
installClipboardSpy();
// --- Steps 1-5 (recap from CE-WP-0002-T09) -------------------------
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
await act(async () => {
viewerSnapshot.onSelectionCaptured!(
syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
[{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
);
});
await user.type(
screen.getByPlaceholderText(/Add a one-line comment/),
"Export E2E commentary",
);
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await screen.findByText(/Export E2E commentary/);
// --- Step 10: click Export → Copy as Markdown ----------------------
// Reinstall after render so happy-dom's prototype reset (observed
// between beforeEach and the actual click) doesn't swallow the spy.
installClipboardSpy();
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(
await screen.findByRole("menuitem", { name: "Copy as Markdown" }),
);
// --- Step 11: clipboard contains the full citation card ------------
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
// Quote text from the manifest fixture.
expect(written).toContain(FIXTURE.known_good_quote);
// Document title — App's load path uses the fixture filename as
// the document title (via ingestPdf options.filename).
expect(written).toContain(FIXTURE.filename);
// Commentary entered above.
expect(written).toContain("Export E2E commentary");
// Open-context URL of the canonical shape. The doc and annotation
// ids are randomly minted; assert via regex.
expect(written).toMatch(/\(\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+\)/);
// Success toast surfaces to the user.
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("success");
expect(toast.textContent).toContain("Copied as Markdown");
},
);
});

View File

@@ -8,10 +8,10 @@ repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
state_hub_workstream_id: 539919a4-f876-42b7-a28c-1a754c333139
status: todo
status: done
owner: Bernd
created: 2026-05-24
updated: 2026-05-24
updated: 2026-05-25
depends_on_workplan: CE-WP-0002
spec_refs:
- wiki/ProductRequirementsDocument.md
@@ -52,7 +52,7 @@ T01 (CitationCard type + open-context URL convention)
id: CE-WP-0004-T01
state_hub_task_id: c15369da-0c42-4b5b-9d8c-cb91b316850f
priority: high
status: todo
status: done
```
Under `src/shared/`:
@@ -74,7 +74,7 @@ scheme stays the same.
id: CE-WP-0004-T02
state_hub_task_id: 4f94d27e-3727-4ad3-8f42-b0bb7ca74cb7
priority: high
status: todo
status: done
depends_on: [T01]
```
@@ -105,7 +105,7 @@ Unit tests: snapshot a few rendered cards against fixtures.
id: CE-WP-0004-T03
state_hub_task_id: 40c1aa86-64a1-4216-9e33-29fddf9c4d62
priority: high
status: todo
status: done
depends_on: [T01]
```
@@ -129,7 +129,7 @@ here — it ships in a later workplan.
id: CE-WP-0004-T04
state_hub_task_id: 0fa3c7e4-6868-4010-ade4-99a921ab0578
priority: medium
status: todo
status: done
depends_on: [T02, T03]
```
@@ -152,7 +152,7 @@ Markdown (the most common action).
id: CE-WP-0004-T05
state_hub_task_id: 8d1993b3-21a8-4be9-9ab1-29aa97e8301c
priority: medium
status: todo
status: done
depends_on: [T04]
```