diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx
index 691e3812e0..a064acdf18 100644
--- a/demos/custom-ui/src/App.tsx
+++ b/demos/custom-ui/src/App.tsx
@@ -7,6 +7,9 @@ import { SelectionPopover } from './components/SelectionPopover';
import { ContextMenu } from './components/ContextMenu';
import { ContextMenuRegistrations } from './components/ContextMenuRegistrations';
import { useDecidedChanges } from './components/useDecidedChanges';
+import { CitationsPanel } from './components/CitationsPanel';
+import { CitationHighlights } from './components/CitationHighlights';
+import { CitationPopover } from './components/CitationPopover';
export function App() {
return (
@@ -29,6 +32,12 @@ function AppInner() {
// the simplest path; a real product might dispatch through a state
// store, but the example keeps the wiring obvious.
const [composeOpen, setComposeOpen] = useState(false);
+ // Sidebar tab selection. Activity is the default; Sources is the
+ // citation/RAG panel built on editor.doc.metadata.* (SD-3208). No
+ // creation flow lives in the selection popover — citations arrive
+ // via "Generate draft with sources," which is how Harvey-class
+ // legal-AI products surface citations.
+ const [activeTab, setActiveTab] = useState<'activity' | 'sources'>('activity');
// Shared decided-changes store. Both ActivitySidebar (per-card
// accept/reject buttons) and the right-click context menu route
// through `decided.decideChange` so the Resolved audit row shows
@@ -47,36 +56,50 @@ function AppInner() {
return (
-
Contract Review Workspace
- Memorandum · review pending
-
+
Contract Review Workspace
+ Memorandum · review pending
+
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
);
}
diff --git a/demos/custom-ui/src/components/CitationHighlights.tsx b/demos/custom-ui/src/components/CitationHighlights.tsx
new file mode 100644
index 0000000000..d1323c8b23
--- /dev/null
+++ b/demos/custom-ui/src/components/CitationHighlights.tsx
@@ -0,0 +1,134 @@
+import { useEffect, useMemo, useState } from 'react';
+import type { ViewportRect } from 'superdoc/ui';
+import { useSuperDocContentControls, useSuperDocUI } from 'superdoc/ui/react';
+import { useCitations } from './useCitations';
+
+/**
+ * Renders absolute-positioned overlay rectangles on every cited span.
+ *
+ * Two-step lookup. `editor.doc.metadata.*` keys by the metadata id
+ * (which is the SDT's `w:tag`); `ui.contentControls.getRect({ id })`
+ * keys by the SDT's PM node id (which the painter stamps as
+ * `data-sdt-id`). These are different identifiers. The contentControls
+ * slice surfaces both per item (`target.nodeId` + `properties.tag`),
+ * so we build a tag → nodeId map and translate at measure time.
+ *
+ * `getRect` returns `rects[]` — one ViewportRect per painted line of a
+ * wrapped span — so line-wrapped citations get clean per-line
+ * underlines without spilling across the page margin.
+ *
+ * Remeasure triggers: window scroll/resize, ResizeObserver on the
+ * editor canvas (catches pagination/zoom), and MutationObserver on
+ * the canvas DOM (catches text edits that move cited spans without
+ * resizing the canvas). All paths funnel through a single rAF tick
+ * to coalesce bursts of keystrokes into one remeasure per frame.
+ *
+ * This is demo-tier instrumentation. A library would expose a
+ * dedicated layout/transaction event rather than observing the DOM.
+ */
+type HighlightEntry = { metadataId: string; tooltip: string; rects: ViewportRect[] };
+
+type CCItem = { target?: { nodeId?: string }; properties?: { tag?: string } };
+
+export function CitationHighlights() {
+ const ui = useSuperDocUI();
+ const { citations } = useCitations();
+ const cc = useSuperDocContentControls();
+ const [entries, setEntries] = useState([]);
+
+ // tag (= metadata id) → PM node id. Refreshes whenever the slice
+ // items array reference changes.
+ const tagToNodeId = useMemo(() => {
+ const map = new Map();
+ for (const item of (cc.items ?? []) as unknown as CCItem[]) {
+ const tag = item.properties?.tag;
+ const nodeId = item.target?.nodeId;
+ if (typeof tag === 'string' && typeof nodeId === 'string') {
+ map.set(tag, nodeId);
+ }
+ }
+ return map;
+ }, [cc.items]);
+
+ useEffect(() => {
+ if (!ui) {
+ setEntries([]);
+ return;
+ }
+
+ const remeasure = () => {
+ const next: HighlightEntry[] = [];
+ for (const c of citations) {
+ const nodeId = tagToNodeId.get(c.id);
+ if (!nodeId) continue;
+ const result = ui.contentControls.getRect({ id: nodeId });
+ if (!result.success) continue;
+ next.push({
+ metadataId: c.id,
+ tooltip: `${c.payload.displayText} (${c.payload.citationId})`,
+ rects: result.rects,
+ });
+ }
+ setEntries(next);
+ };
+
+ // Coalesce burst triggers (multi-mutation keystrokes, ResizeObserver
+ // firing alongside MutationObserver, etc.) into one remeasure per frame.
+ let rafHandle: number | null = null;
+ const scheduleRemeasure = () => {
+ if (rafHandle !== null) return;
+ rafHandle = requestAnimationFrame(() => {
+ rafHandle = null;
+ remeasure();
+ });
+ };
+
+ remeasure();
+ window.addEventListener('scroll', scheduleRemeasure, true);
+ window.addEventListener('resize', scheduleRemeasure);
+
+ const canvas = document.querySelector('.editor-canvas');
+ const resizeObserver = canvas ? new ResizeObserver(scheduleRemeasure) : null;
+ if (canvas && resizeObserver) resizeObserver.observe(canvas);
+
+ // Skip the DOM-mutation observer when there are no citations to track —
+ // keeps the demo from observing the editor body when there's nothing to update.
+ const mutationObserver =
+ canvas && citations.length > 0
+ ? new MutationObserver(scheduleRemeasure)
+ : null;
+ if (canvas && mutationObserver) {
+ mutationObserver.observe(canvas, { childList: true, subtree: true, characterData: true });
+ }
+
+ return () => {
+ window.removeEventListener('scroll', scheduleRemeasure, true);
+ window.removeEventListener('resize', scheduleRemeasure);
+ resizeObserver?.disconnect();
+ mutationObserver?.disconnect();
+ if (rafHandle !== null) cancelAnimationFrame(rafHandle);
+ };
+ }, [ui, citations, tagToNodeId]);
+
+ return (
+
+ );
+}
diff --git a/demos/custom-ui/src/components/GenerateDraftButton.tsx b/demos/custom-ui/src/components/GenerateDraftButton.tsx
new file mode 100644
index 0000000000..f566ca52e9
--- /dev/null
+++ b/demos/custom-ui/src/components/GenerateDraftButton.tsx
@@ -0,0 +1,116 @@
+import { useState } from 'react';
+import { useSuperDocHost } from 'superdoc/ui/react';
+import { MOCK_DRAFTS, computeCitationTargets } from './mockDraft';
+import { useCitations } from './useCitations';
+
+type PMNode = {
+ type?: { name?: string };
+ attrs?: Record;
+ isTextblock?: boolean;
+ textContent?: string;
+};
+
+type EditorHandle = {
+ doc?: {
+ insert?: (input: { value: string; type?: string }) => unknown;
+ };
+ state?: {
+ doc?: PMNode & { descendants: (fn: (node: PMNode) => boolean | void) => void };
+ };
+};
+
+/**
+ * Find the blockId of the last text block that contains `text`. We use
+ * this instead of trusting the `editor.doc.insert` receipt because in
+ * the browser build it doesn't always carry `target.blockId` — different
+ * surface from the SDK / CLI path.
+ */
+function findBlockIdContaining(editor: EditorHandle, text: string): string | null {
+ const doc = editor.state?.doc;
+ if (!doc) return null;
+ let last: string | null = null;
+ doc.descendants((node) => {
+ if (!node.isTextblock) return;
+ const content = node.textContent ?? '';
+ if (content.includes(text)) {
+ const id = node.attrs?.sdBlockId;
+ if (typeof id === 'string') last = id;
+ }
+ });
+ return last;
+}
+
+/**
+ * Mocked "AI draft with sources" entry point.
+ *
+ * Single-shot: one click inserts every `MOCK_DRAFTS` paragraph at the
+ * end of the document via `editor.doc.insert` and attaches each
+ * citation via `metadata.attach`. The button is hidden once the demo
+ * already has citations — repeat clicks would collide on the hardcoded
+ * sample citation ids. To re-run the demo, remove the citations from
+ * the sidebar or reload the page.
+ *
+ * The button is a stand-in for a chat/prompt-driven RAG flow; a real
+ * integration replaces this with whatever surface drives generation in
+ * the customer's product.
+ */
+export function GenerateDraftButton() {
+ const host = useSuperDocHost();
+ const { citations, attach } = useCitations();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Single-shot: hide once the demo already has citations — unless a
+ // partial-attach failure left an error mid-flight. Keeping the
+ // component mounted while there's an error preserves the message
+ // (and the button, so the user can retry or read it) instead of
+ // silently disappearing on the next render.
+ if (citations.length > 0 && !error) return null;
+
+ const generate = () => {
+ setError(null);
+ const editor = (host as unknown as { activeEditor?: EditorHandle }).activeEditor;
+ const insert = editor?.doc?.insert;
+ if (!insert) {
+ setError('editor.doc.insert is not available on this build.');
+ return;
+ }
+
+ setBusy(true);
+ try {
+ for (const draft of MOCK_DRAFTS) {
+ insert({ value: draft.text });
+ const blockId = findBlockIdContaining(editor!, draft.text.slice(0, 40));
+ if (!blockId) {
+ setError('Could not locate the inserted paragraph in the document.');
+ return;
+ }
+ const targets = computeCitationTargets(draft, blockId);
+ for (const { target, payload } of targets) {
+ const r = attach(target, payload, payload.citationId);
+ if ('error' in r) {
+ setError(`metadata.attach failed for ${payload.citationId}: ${r.error}`);
+ return;
+ }
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ Mocked stand-in for a chat/prompt workflow. Inserts sample text with citations already
+ attached, the way a real legal-AI product would emit source-grounded output.
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/demos/custom-ui/src/components/citations-types.ts b/demos/custom-ui/src/components/citations-types.ts
new file mode 100644
index 0000000000..430edb6142
--- /dev/null
+++ b/demos/custom-ui/src/components/citations-types.ts
@@ -0,0 +1,143 @@
+/**
+ * Local type declarations for the metadata.* citation flow.
+ *
+ * `editor.doc.metadata.*` exists at runtime (SD-3104) but the
+ * published SuperDocEditorLike `doc?` stub hasn't caught up yet, so
+ * the demo declares the slice it uses here.
+ */
+
+export type SelectionPoint =
+ | { kind: 'text'; blockId: string; offset: number }
+ | { kind: 'nodeEdge'; node: { kind: 'block'; nodeType: string; nodeId: string }; edge: 'before' | 'after' };
+
+export type SelectionTarget = {
+ kind: 'selection';
+ start: SelectionPoint;
+ end: SelectionPoint;
+};
+
+export type TextSegment = { blockId: string; range: { start: number; end: number } };
+export type TextTarget = { kind: 'text'; segments: TextSegment[] };
+
+/**
+ * Per-citation payload. JSON-serializable so it survives DOCX round-trips.
+ *
+ * The SDT `w:tag` is the document anchor key SuperDoc owns. Citation
+ * identity is the customer's foreign key inside the payload:
+ *
+ * - `citationId`: stable id from the customer's citation database.
+ * - `sourceId`: stable id of the cited source (the customer's record
+ * key, not a URL — URLs can change).
+ * - `sourceType`: shape the cited record takes in the customer's domain.
+ * Drives rendering choices (KeyCite signal for cases, etc.) without
+ * forcing the customer to teach SuperDoc about every doc type.
+ * - `provider`: which system stores the source (e.g. `'lexisnexis'`,
+ * `'westlaw'`, `'customer-dms'`). Opaque to SuperDoc; consumer apps
+ * route `deepLink` resolution and verification by it.
+ * - `displayText`: fallback label shown when the customer hasn't
+ * resolved `sourceId` to a live record. Display only, not identity.
+ * - `locator`: optional pinpoint (page, section, paragraph).
+ * - `excerpt`: quoted supporting passage from the source. Shown in the
+ * hover popover so the lawyer can verify without leaving the doc.
+ * - `deepLink`: optional URL the customer's app resolves to the
+ * source's primary view.
+ * - `confidence`: optional AI-generated confidence score 0..1. Render
+ * only when present — not every provider emits it.
+ * - `createdAt`: optional ISO-8601 timestamp of when the citation was
+ * attached.
+ *
+ * Deliberately omitted from v1: `verificationStatus` and
+ * `sourceVersionId`. Both are render-time concerns — KeyCite / Shepard
+ * signals change over time, so persisting them in the DOCX payload
+ * would go stale. The consumer's app should compute verification
+ * status at render time from `sourceId` + `provider`.
+ */
+export type CitationPayload = {
+ citationId: string;
+ sourceId: string;
+ sourceType: 'case' | 'statute' | 'contract' | 'memo' | 'precedent';
+ provider: string;
+ displayText: string;
+ locator?: string;
+ excerpt: string;
+ deepLink?: string;
+ confidence?: number;
+ createdAt?: string;
+};
+
+export type CitationInfo = {
+ id: string;
+ namespace: string;
+ partName: string;
+ payload: CitationPayload;
+};
+
+export type MetadataAttachResult =
+ | { success: true; id: string; namespace: string; partName: string }
+ | { success: false; failure: { code: string; message: string } };
+
+export type MetadataMutationResult =
+ | { success: true; id: string }
+ | { success: false; failure: { code: string; message: string } };
+
+/** The metadata.* slice this demo reaches into via `editor.doc.metadata`. */
+export type MetadataDocApi = {
+ attach(input: {
+ target: SelectionTarget;
+ namespace: string;
+ payload: unknown;
+ id?: string;
+ }): MetadataAttachResult;
+ list(input?: { namespace?: string; within?: SelectionTarget }): {
+ items: Array<{ id: string; namespace: string; partName: string }>;
+ total: number;
+ };
+ get(input: { id: string }): { id: string; namespace: string; partName: string; payload: unknown } | null;
+ update(input: { id: string; payload: unknown }): MetadataMutationResult;
+ remove(input: { id: string }): MetadataMutationResult;
+ resolve(input: { id: string }): { id: string; target: SelectionTarget } | null;
+};
+
+export const CITATIONS_NAMESPACE = 'urn:superdoc:demo:citations:1';
+
+export function isCitationPayload(payload: unknown): payload is CitationPayload {
+ if (typeof payload !== 'object' || payload === null) return false;
+ const p = payload as Record;
+ return (
+ typeof p.citationId === 'string' &&
+ typeof p.sourceId === 'string' &&
+ typeof p.sourceType === 'string' &&
+ typeof p.provider === 'string' &&
+ typeof p.displayText === 'string' &&
+ typeof p.excerpt === 'string'
+ );
+}
+
+/**
+ * Convert the SelectionInfo TextTarget (from `ui.state.selection.target`)
+ * to a SelectionTarget for `metadata.attach`. v1 requires both endpoints
+ * in the same block; returns null when the selection is empty,
+ * multi-block, or otherwise unsupported.
+ */
+export function textTargetToSelectionTarget(target: TextTarget | null | undefined): SelectionTarget | null {
+ if (!target || target.segments.length === 0) return null;
+ const first = target.segments[0]!;
+ const last = target.segments[target.segments.length - 1]!;
+ if (first.blockId !== last.blockId) return null;
+ if (last.range.end <= first.range.start) return null;
+ return {
+ kind: 'selection',
+ start: { kind: 'text', blockId: first.blockId, offset: first.range.start },
+ end: { kind: 'text', blockId: last.blockId, offset: last.range.end },
+ };
+}
+
+/** Reverse: SelectionTarget (from metadata.resolve) → TextTarget (for viewport.scrollIntoView). */
+export function selectionTargetToTextTarget(target: SelectionTarget | null): TextTarget | null {
+ if (!target) return null;
+ if (target.start.kind !== 'text' || target.end.kind !== 'text') return null;
+ return {
+ kind: 'text',
+ segments: [{ blockId: target.start.blockId, range: { start: target.start.offset, end: target.end.offset } }],
+ };
+}
diff --git a/demos/custom-ui/src/components/mockDraft.ts b/demos/custom-ui/src/components/mockDraft.ts
new file mode 100644
index 0000000000..0ad4ff1e16
--- /dev/null
+++ b/demos/custom-ui/src/components/mockDraft.ts
@@ -0,0 +1,120 @@
+/**
+ * Mocked stand-in for an AI/RAG pipeline. Holds pre-canned text plus
+ * pre-canned citation specs — the shape a generator would emit (text
+ * plus an array of cited spans plus payloads). The demo inserts the
+ * text and attaches each citation to the span it references; no model
+ * is invoked.
+ *
+ * A real integration replaces this with whatever surface drives
+ * generation in the customer's product (typically a chat panel plus
+ * an Insert into document action).
+ */
+import type { CitationPayload, SelectionTarget } from './citations-types';
+
+/** One mock draft to insert — a paragraph plus the spans it cites. */
+export type MockDraft = {
+ /** Paragraph text inserted at the end of the document. */
+ text: string;
+ /** Citations attached to specific phrases within `text`. */
+ citations: Array<{
+ /** Exact substring of `text` the citation anchors to. Case-sensitive. */
+ phrase: string;
+ /** The payload the (mock) AI generated alongside the prose. */
+ payload: CitationPayload;
+ }>;
+};
+
+const NOW = () => new Date().toISOString();
+
+export const MOCK_DRAFTS: MockDraft[] = [
+ {
+ text:
+ "The duty of care requires officers to act with the care a person in a like position would reasonably believe appropriate under similar circumstances. " +
+ "In Beacon Bio v. FTC, the Ninth Circuit confirmed that this standard applies to interim operating decisions during a pending merger.",
+ citations: [
+ {
+ phrase: 'duty of care',
+ payload: {
+ citationId: 'cite-001',
+ sourceId: 'restatement-torts-3rd-s6',
+ sourceType: 'statute',
+ provider: 'lexisnexis',
+ displayText: 'Restatement (Third) of Torts § 6',
+ locator: '§ 6',
+ excerpt:
+ 'An actor must exercise reasonable care, defined as the care that a reasonable person would exercise under like circumstances.',
+ deepLink: 'https://example.com/lexis/restatement-torts/6',
+ confidence: 0.92,
+ createdAt: NOW(),
+ },
+ },
+ {
+ phrase: 'Beacon Bio v. FTC',
+ payload: {
+ citationId: 'cite-002',
+ sourceId: 'case-beacon-bio-ftc-2024',
+ sourceType: 'case',
+ provider: 'westlaw',
+ displayText: 'Beacon Bio v. FTC, 12 F.4th 100 (9th Cir. 2024)',
+ locator: '12 F.4th 100, 112',
+ excerpt:
+ 'We hold that the duty of care extends to interim operating decisions during the pendency of a noticed transaction.',
+ deepLink: 'https://example.com/westlaw/beacon-bio-ftc',
+ confidence: 0.88,
+ createdAt: NOW(),
+ },
+ },
+ ],
+ },
+ {
+ text:
+ "Confidentiality obligations under the agreement survive disclosure for five years, consistent with the precedent in the firm's Mutual NDA template. " +
+ "Acquirors should treat this as a non-negotiable floor for transactions of this size.",
+ citations: [
+ {
+ phrase: 'Mutual NDA template',
+ payload: {
+ citationId: 'cite-003',
+ sourceId: 'firm-template-mutual-nda',
+ sourceType: 'precedent',
+ provider: 'customer-dms',
+ displayText: 'Firm Precedent: Mutual NDA Template v3',
+ locator: '§ 4.2',
+ excerpt:
+ 'Each party shall protect the other party\u2019s Confidential Information for a period of five (5) years from the date of disclosure.',
+ deepLink: 'https://example.com/dms/templates/mutual-nda-v3',
+ confidence: 0.95,
+ createdAt: NOW(),
+ },
+ },
+ ],
+ },
+];
+
+/**
+ * Compute the SelectionTargets for each citation in a draft, given the
+ * blockId the text was inserted into. Phrase offsets are character
+ * positions within `draft.text`. v1 of `metadata.attach` requires
+ * same-paragraph anchors; the mock inserts a single paragraph so this
+ * constraint is satisfied by construction.
+ */
+export function computeCitationTargets(
+ draft: MockDraft,
+ blockId: string,
+): Array<{ target: SelectionTarget; payload: CitationPayload }> {
+ const out: Array<{ target: SelectionTarget; payload: CitationPayload }> = [];
+ for (const c of draft.citations) {
+ const start = draft.text.indexOf(c.phrase);
+ if (start < 0) continue;
+ const end = start + c.phrase.length;
+ out.push({
+ target: {
+ kind: 'selection',
+ start: { kind: 'text', blockId, offset: start },
+ end: { kind: 'text', blockId, offset: end },
+ },
+ payload: c.payload,
+ });
+ }
+ return out;
+}
diff --git a/demos/custom-ui/src/components/useCitations.ts b/demos/custom-ui/src/components/useCitations.ts
new file mode 100644
index 0000000000..09f702fc94
--- /dev/null
+++ b/demos/custom-ui/src/components/useCitations.ts
@@ -0,0 +1,129 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useSuperDocContentControls, useSuperDocHost } from 'superdoc/ui/react';
+import {
+ CITATIONS_NAMESPACE,
+ isCitationPayload,
+ type CitationInfo,
+ type CitationPayload,
+ type MetadataDocApi,
+ type SelectionTarget,
+} from './citations-types';
+
+/**
+ * Reach into `editor.doc.metadata` even though the published
+ * SuperDocEditorLike `doc?` stub doesn't expose it yet. v1 path
+ * because the metadata.* surface lands in SD-3104; the controller
+ * type can catch up later.
+ */
+function readMetadataApi(host: ReturnType): MetadataDocApi | null {
+ if (!host) return null;
+ const editor = (host as unknown as { activeEditor?: { doc?: { metadata?: MetadataDocApi } } }).activeEditor;
+ return editor?.doc?.metadata ?? null;
+}
+
+/** Hydrate a list of citation entries by fetching their payloads. */
+function hydrate(api: MetadataDocApi): CitationInfo[] {
+ const result = api.list({ namespace: CITATIONS_NAMESPACE });
+ const out: CitationInfo[] = [];
+ for (const summary of result.items) {
+ const info = api.get({ id: summary.id });
+ if (!info || !isCitationPayload(info.payload)) continue;
+ out.push({ id: info.id, namespace: info.namespace, partName: info.partName, payload: info.payload });
+ }
+ return out;
+}
+
+export type UseCitationsResult = {
+ /** Current list. Refreshes when content-controls slice ticks or after each mutation. */
+ citations: CitationInfo[];
+ /** True until SuperDoc is ready. */
+ loading: boolean;
+ /**
+ * Attach a citation to a text-range SelectionTarget. Caller passes the
+ * full payload — in the customer-shaped flow this is built by the
+ * generation pipeline (mock or real), not by a manual composer.
+ */
+ attach(target: SelectionTarget, payload: CitationPayload, id?: string): { id: string } | { error: string };
+ /** Replace an existing citation's payload (e.g. edit displayText or locator). */
+ update(id: string, payload: CitationPayload): { error?: string };
+ /** Remove a citation; strips both anchor + payload. */
+ remove(id: string): { error?: string };
+ /** Resolve a citation id to its current SelectionTarget. */
+ resolve(id: string): SelectionTarget | null;
+ /** Force a re-list (rarely needed; the contentControls slice usually covers it). */
+ refresh(): void;
+};
+
+/**
+ * One-stop hook for the citation demo. Wraps `editor.doc.metadata.*`
+ * and re-lists whenever the content-controls slice changes — that
+ * slice fires for every SDT mutation, so attach/remove flow automatic
+ * refreshes through it. Adapter mutations also call `refresh()`
+ * directly to cover the brief window before the slice tick lands.
+ */
+export function useCitations(): UseCitationsResult {
+ const host = useSuperDocHost();
+ // Subscribe to the contentControls slice so any SDT mutation re-runs us.
+ const cc = useSuperDocContentControls();
+ const [citations, setCitations] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(() => {
+ const api = readMetadataApi(host);
+ if (!api) return;
+ setCitations(hydrate(api));
+ setLoading(false);
+ }, [host]);
+
+ // Re-run on host availability and on every contentControls slice tick.
+ useEffect(() => {
+ refresh();
+ }, [refresh, cc.items, cc.activeId]);
+
+ const attach = useCallback(
+ (target: SelectionTarget, payload: CitationPayload, id?: string): { id: string } | { error: string } => {
+ const api = readMetadataApi(host);
+ if (!api) return { error: 'Editor not ready.' };
+ const result = api.attach({ target, namespace: CITATIONS_NAMESPACE, payload, id });
+ if (!result.success) return { error: result.failure.message };
+ refresh();
+ return { id: result.id };
+ },
+ [host, refresh],
+ );
+
+ const update = useCallback(
+ (id: string, payload: CitationPayload) => {
+ const api = readMetadataApi(host);
+ if (!api) return { error: 'Editor not ready.' };
+ const result = api.update({ id, payload });
+ if (!result.success) return { error: result.failure.message };
+ refresh();
+ return {};
+ },
+ [host, refresh],
+ );
+
+ const remove = useCallback(
+ (id: string) => {
+ const api = readMetadataApi(host);
+ if (!api) return { error: 'Editor not ready.' };
+ const result = api.remove({ id });
+ if (!result.success) return { error: result.failure.message };
+ refresh();
+ return {};
+ },
+ [host, refresh],
+ );
+
+ const resolve = useCallback(
+ (id: string): SelectionTarget | null => {
+ const api = readMetadataApi(host);
+ if (!api) return null;
+ return api.resolve({ id })?.target ?? null;
+ },
+ [host],
+ );
+
+ return { citations, loading, attach, update, remove, resolve, refresh };
+}
diff --git a/demos/custom-ui/src/styles.css b/demos/custom-ui/src/styles.css
index 0d79c2d23d..cbc9c19311 100644
--- a/demos/custom-ui/src/styles.css
+++ b/demos/custom-ui/src/styles.css
@@ -614,3 +614,237 @@ button {
margin: 4px 0;
background: var(--border);
}
+
+/* SD-3208 — AI citation showcase styles. */
+
+.sidebar-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border);
+}
+.sidebar-tab {
+ flex: 1;
+ padding: 10px 12px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+.sidebar-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 600;
+}
+.sidebar-tab:hover {
+ color: var(--text);
+}
+
+.citations-panel {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.citations-empty {
+ font-size: 13px;
+ color: var(--text-muted);
+ text-align: center;
+ padding: 16px 8px;
+}
+
+.generate-draft {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+.generate-draft-btn {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--accent);
+ background: var(--accent);
+ color: #ffffff;
+ border-radius: 4px;
+ font-weight: 500;
+ font-size: 13px;
+}
+.generate-draft-btn:disabled {
+ opacity: 0.5;
+}
+.generate-draft-help {
+ margin: 0;
+ font-size: 11px;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+
+.references-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.reference-card {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 10px 12px;
+ background: var(--bg);
+ box-shadow: var(--shadow);
+}
+.reference-card-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.reference-source-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+}
+.reference-source-type {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: var(--bg-muted);
+}
+.reference-source-meta {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-bottom: 8px;
+}
+.reference-deeplink {
+ color: var(--accent);
+ text-decoration: none;
+}
+.reference-deeplink:hover {
+ text-decoration: underline;
+}
+.reference-citations {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--border);
+}
+.reference-citation {
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+}
+.reference-citation:last-child {
+ border-bottom: none;
+}
+.reference-citation-line {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: baseline;
+ font-size: 11px;
+ margin-bottom: 4px;
+}
+.reference-citation-id {
+ font-family: 'SF Mono', Menlo, monospace;
+ color: var(--text);
+ font-weight: 600;
+}
+.reference-citation-locator {
+ color: var(--text-muted);
+}
+.reference-citation-confidence {
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+.reference-citation-actions {
+ display: flex;
+ gap: 6px;
+}
+.reference-citation-actions button {
+ font-size: 11px;
+ padding: 3px 8px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--bg);
+ color: var(--text);
+}
+.reference-citation-actions button:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.citation-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-top: 6px;
+}
+.citation-editor .composer-input {
+ font-size: 12px;
+ padding: 4px 6px;
+}
+
+/* Highlight overlay for cited spans in the body. Pointer-events
+ disabled so the user can still select / interact with the painted
+ text underneath. */
+.citation-highlights {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 5;
+}
+.citation-highlight {
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+ border-bottom: 2px solid var(--accent);
+ pointer-events: none;
+ border-radius: 2px;
+ mix-blend-mode: multiply;
+}
+
+/* Hover popover — light themed to match the rest of the demo's
+ surfaces (context menu, selection popover). Shows excerpt
+ prominently so the lawyer can verify without clicking through. */
+.citation-popover {
+ background: var(--bg);
+ color: var(--text);
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 12px;
+ max-width: 320px;
+ box-shadow: var(--shadow);
+ z-index: 30;
+}
+.citation-popover-source {
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+.citation-popover-locator {
+ color: var(--text-muted);
+ font-family: 'SF Mono', Menlo, monospace;
+ font-size: 11px;
+ margin-bottom: 6px;
+}
+.citation-popover-excerpt {
+ font-style: italic;
+ border-left: 2px solid var(--accent);
+ padding-left: 8px;
+ color: var(--text);
+ font-size: 12px;
+ line-height: 1.4;
+ margin-bottom: 6px;
+}
+.citation-popover-meta {
+ color: var(--text-muted);
+ font-size: 10px;
+}
+.citation-popover-provider {
+ text-transform: lowercase;
+}
+
+.composer-error {
+ font-size: 12px;
+ color: var(--danger);
+ padding: 4px 0;
+}