generated from coulomb/repo-seed
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:
115
docs/decisions/ADR-0007-citation-card-format.md
Normal file
115
docs/decisions/ADR-0007-citation-card-format.md
Normal 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.
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
export * from "./events";
|
||||
export * from "./repos";
|
||||
export * from "./services";
|
||||
export * from "./rendering";
|
||||
export {
|
||||
SNAPSHOT_VERSION,
|
||||
attachPersister,
|
||||
|
||||
172
src/engine/rendering/html.test.ts
Normal file
172
src/engine/rendering/html.test.ts
Normal 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&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&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\"><<value>> & 'inner'</blockquote>",
|
||||
);
|
||||
// Source label escaping
|
||||
expect(out).toContain(
|
||||
"<cite class=\"citation-card__source\">Order "draft" & more</cite>",
|
||||
);
|
||||
// Commentary escaping — and especially: no raw <script>
|
||||
expect(out).not.toContain("<script>");
|
||||
expect(out).toContain(
|
||||
"<div class=\"citation-card__commentary\">Notes: <script>alert("xss")</script> & 'untrusted'</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=/);
|
||||
});
|
||||
});
|
||||
92
src/engine/rendering/html.ts
Normal file
92
src/engine/rendering/html.ts
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
8
src/engine/rendering/index.ts
Normal file
8
src/engine/rendering/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
renderCitationCardMarkdown,
|
||||
type RenderCitationCardMarkdownInput,
|
||||
} from "./markdown";
|
||||
export {
|
||||
renderCitationCardHtml,
|
||||
type RenderCitationCardHtmlInput,
|
||||
} from "./html";
|
||||
194
src/engine/rendering/markdown.test.ts
Normal file
194
src/engine/rendering/markdown.test.ts
Normal 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)
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
68
src/engine/rendering/markdown.ts
Normal file
68
src/engine/rendering/markdown.ts
Normal 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";
|
||||
}
|
||||
75
src/shared/citation-card-source.test.ts
Normal file
75
src/shared/citation-card-source.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
26
src/shared/citation-card-source.ts
Normal file
26
src/shared/citation-card-source.ts
Normal 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;
|
||||
}
|
||||
35
src/shared/citation-card.ts
Normal file
35
src/shared/citation-card.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
32
src/shared/open-context-url.test.ts
Normal file
32
src/shared/open-context-url.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
36
src/shared/open-context-url.ts
Normal file
36
src/shared/open-context-url.ts
Normal 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}`;
|
||||
}
|
||||
210
src/work/EvidenceSidebar.dom.test.tsx
Normal file
210
src/work/EvidenceSidebar.dom.test.tsx
Normal 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;
|
||||
@@ -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 }}>
|
||||
“{quote.slice(0, 140)}
|
||||
{quote.length > 140 ? "…" : ""}”
|
||||
</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 }}>
|
||||
“{quote.slice(0, 140)}
|
||||
{quote.length > 140 ? "…" : ""}”
|
||||
</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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
src/work/useExportEvidence.ts
Normal file
89
src/work/useExportEvidence.ts
Normal 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 };
|
||||
}
|
||||
215
tests/integration/citation-card-export-e2e.dom.test.tsx
Normal file
215
tests/integration/citation-card-export-e2e.dom.test.tsx
Normal 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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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]
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user