commit 0284f2cfd67668c8b0a95a66190755cf314e1ba8
parent c287d7dd7112680482363cc556c44eefa1888de7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 22:57:27 -0400
umtool: the article reader -- anchored notes, evidence, the evidence walk
- /sites/<site>/<report>: the article in a ~70ch column (common's markdown,
citations as buttons), its video under the title, notes in a rail (a drawer
below 1100px). Select text → Note (or n); "+ note" on a heading; a citation
opens its evidence with a note box; "+ whole article". Notes are found again
in the text as it is now (lib/annotations/anchor.mjs) and marked; a quote
that is gone is listed as orphaned. Filters open/resolved/all, resolve,
won't fix, reopen, replies (agent badged), j/k/r/e/n/Esc, ?note= deep link,
"copy agent brief" (/api/notes/context)
- ?tab=source: the article's workspace files, its draft opened
- /sites/<site>/<report>/evidence: one citation per screen, j/k, space, n
- the report video renders in ONE slot (components/articles/ArticleVideo.tsx)
for the timed-notes player to replace
- e2e: sites, article-notes, article-evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
12 files changed, 1770 insertions(+), 0 deletions(-)
diff --git a/umtool/app/sites/[site]/[report]/evidence/page.tsx b/umtool/app/sites/[site]/[report]/evidence/page.tsx
@@ -0,0 +1,55 @@
+import { notFound } from "next/navigation";
+import { reportCitationNumbers } from "yt-dlp-transcript-common/lib/report/uses";
+import BrowseHeader from "@/components/BrowseHeader";
+import EvidenceWalk, { type WalkItem } from "@/components/articles/EvidenceWalk";
+import { readArticleNotes, readReportFile, siteById } from "@/lib/articles/sites";
+import { corpusNotesFile } from "@/lib/paths";
+import type { NotesRead } from "@/lib/annotations/types";
+
+export const dynamic = "force-dynamic";
+
+// Every citation of one article, one per screen, in the article's own
+// numbering. `?c=<citationId>` opens on that citation.
+export default async function EvidenceWalkPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ site: string; report: string }>;
+ searchParams: Promise<{ c?: string }>;
+}) {
+ const { site: siteId, report: reportId } = await params;
+ const { c } = await searchParams;
+ const site = siteById(siteId);
+ if (!site) notFound();
+ const read = await readReportFile(site.siteId, reportId);
+ if (!read.report) notFound();
+ const r = read.report;
+ const numbers = reportCitationNumbers(r);
+ const items: WalkItem[] = [...numbers.entries()]
+ .sort((a, b) => a[1] - b[1])
+ .map(([id, number]) => ({ id, number, kind: r.citations?.[id]?.kind ?? "?", quote: r.citations?.[id]?.quote ?? "" }));
+ const at = Math.max(0, items.findIndex((i) => i.id === c));
+ const notes = await readArticleNotes(site.siteId, reportId);
+ const initialNotes: NotesRead = {
+ subject: { kind: "article", site: site.siteId, report: reportId },
+ file: corpusNotesFile(site.siteId, reportId) ?? "",
+ token: notes.token,
+ doc: notes.doc,
+ source: notes.doc?.source ?? null,
+ };
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ active="sites"
+ crumbs={[
+ { href: "/sites", label: "sites" },
+ { href: `/sites/${site.siteId}`, label: site.siteId },
+ { href: `/sites/${site.siteId}/${reportId}`, label: reportId },
+ { label: "evidence" },
+ ]}
+ note={`${items.length} citations`}
+ />
+ <EvidenceWalk site={site.siteId} report={reportId} items={items} initial={at} initialNotes={initialNotes} />
+ </div>
+ );
+}
diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx
@@ -0,0 +1,180 @@
+import path from "node:path";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { readRevisionHead, REPORT_HISTORY_GIT_DIRNAME } from "yt-dlp-transcript-common/publish/reportHistory";
+import { siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia";
+import BrowseHeader from "@/components/BrowseHeader";
+import ArticleReader from "@/components/articles/ArticleReader";
+import ArticleVideo from "@/components/articles/ArticleVideo";
+import WorkspacePanel from "@/components/articles/WorkspacePanel";
+import { badgeVariants } from "@/components/ui/badge";
+import { articleView } from "@/lib/articles/article";
+import { articleWorkspaceListing, openWorkspaceFile } from "@/lib/articles/files";
+import { linkedProjects } from "@/lib/articles/links.mjs";
+import { readArticleNotes, readReportFile, siteById, sitesPaths } from "@/lib/articles/sites";
+import { sourceFor } from "@/lib/articles/sources.mjs";
+import { corpusNotesFile } from "@/lib/paths";
+import type { NotesRead } from "@/lib/annotations/types";
+
+export const dynamic = "force-dynamic";
+
+// One article: the reader with its notes (default), or `?tab=source` -- the
+// workspace files it was written from, its draft marked.
+//
+// ?status=open|resolved|all the notes filter it opens with
+// ?note=<id> the note it opens on (the decisions inbox links here)
+// ?ws=&rel= a workspace file open on the source tab
+
+type Search = { tab?: string; status?: string; note?: string; ws?: string; rel?: string };
+
+export default async function ArticlePage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ site: string; report: string }>;
+ searchParams: Promise<Search>;
+}) {
+ const { site: siteId, report: reportId } = await params;
+ const sp = await searchParams;
+ const site = siteById(siteId);
+ if (!site) notFound();
+ const read = await readReportFile(site.siteId, reportId);
+ if (!read.report && read.problems[0]?.message === "no report.json") notFound();
+
+ const [notes, source] = await Promise.all([readArticleNotes(site.siteId, reportId), sourceFor(site.siteId, reportId)]);
+ const links = await linkedProjects(site.siteId, reportId, { workspace: source?.workspace ?? null });
+ const gitDir = path.join(/* turbopackIgnore: true */ siteReportDir(sitesPaths(), site.siteId, reportId), REPORT_HISTORY_GIT_DIRNAME);
+ const head = await readRevisionHead(gitDir).catch(() => null);
+ const published = (site.reports ?? []).includes(reportId);
+ const r = read.report;
+ const tab = sp.tab === "source" ? "source" : "article";
+ const base = `/sites/${site.siteId}/${reportId}`;
+ const media = (file: string) =>
+ `/api/sites/media?site=${encodeURIComponent(site.siteId)}&report=${encodeURIComponent(reportId)}&file=${encodeURIComponent(file)}`;
+
+ const meta = (
+ <div data-article-meta className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-[var(--color-dim)]">
+ <Link href={`/sites/${site.siteId}`} className="hover:text-[var(--color-text)]">
+ {site.siteTitle || site.siteId}
+ </Link>
+ <span data-status={published ? "published" : "draft"} className={badgeVariants({ variant: published ? "on" : "neutral", size: "sm" })}>
+ {published ? "published" : "draft"}
+ </span>
+ {r?.published && <span>published {r.published}</span>}
+ {r?.updated && <span>updated {r.updated}</span>}
+ {head && <span data-revisions={head.revision}>{head.revision} revision{head.revision === 1 ? "" : "s"}</span>}
+ {links.linked.map((p: { id: string }) => (
+ <Link key={p.id} href={`/browse/${p.id}`} data-project-link={p.id} className="font-mono text-[var(--color-sel)] hover:underline">
+ {p.id}
+ </Link>
+ ))}
+ {links.possible.map((p: { id: string }) => (
+ <Link key={p.id} href={`/browse/${p.id}`} className="font-mono hover:underline" title="shares the slug; not linked">
+ {p.id} (possible)
+ </Link>
+ ))}
+ <nav className="ml-auto flex gap-2" aria-label="article views">
+ <Link href={base} aria-current={tab === "article" ? "page" : undefined} className={tab === "article" ? "text-[var(--color-sel)]" : "hover:text-[var(--color-text)]"}>
+ article
+ </Link>
+ <Link href={`${base}?tab=source`} aria-current={tab === "source" ? "page" : undefined} className={tab === "source" ? "text-[var(--color-sel)]" : "hover:text-[var(--color-text)]"}>
+ source
+ </Link>
+ <Link href={`${base}/evidence`} className="hover:text-[var(--color-text)]">
+ evidence
+ </Link>
+ </nav>
+ {source && (
+ <div className="w-full font-mono text-[11px]" title={source.how}>
+ {source.draft && <span>draft {source.draft}</span>}
+ {source.generator && <span className="ml-3">generator {source.generator}</span>}
+ </div>
+ )}
+ </div>
+ );
+
+ const header = (
+ <BrowseHeader
+ active="sites"
+ crumbs={[{ href: "/sites", label: "sites" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { label: reportId }]}
+ note={`${notes.doc?.notes.filter((n) => n.status === "open").length ?? 0} open notes`}
+ />
+ );
+
+ if (!r) {
+ return (
+ <div className="flex h-full flex-col">
+ {header}
+ <main className="deck-main flex-1 p-4">
+ <div className="mx-auto max-w-[72ch] space-y-3">
+ {meta}
+ <p className="text-[13px] text-[var(--color-bad)]">report.json does not read as a report:</p>
+ <ul className="list-disc pl-5 text-[12px] text-[var(--color-dim)]">
+ {read.problems.slice(0, 20).map((p, i) => (
+ <li key={i}>{p.message}</li>
+ ))}
+ </ul>
+ </div>
+ </main>
+ </div>
+ );
+ }
+
+ if (tab === "source") {
+ const ws = await articleWorkspaceListing(source?.workspace);
+ const opened =
+ sp.ws && sp.rel
+ ? await openWorkspaceFile(sp.ws, sp.rel)
+ : ws && source?.draft
+ ? await openWorkspaceFile(ws.name, path.relative(ws.dir, source.draft))
+ : null;
+ const draftRel = ws && source?.draft ? `${ws.name}/${path.relative(ws.dir, source.draft)}` : null;
+ return (
+ <div className="flex h-full flex-col">
+ {header}
+ <main className="deck-main flex-1 p-4">
+ <div className="space-y-4">
+ {meta}
+ <WorkspacePanel
+ workspaces={ws ? [ws] : []}
+ opened={opened}
+ highlight={draftRel}
+ hrefFor={(w, rel) => `${base}?${new URLSearchParams({ tab: "source", ws: w, rel })}`}
+ />
+ </div>
+ </main>
+ </div>
+ );
+ }
+
+ const { view, error } = await articleView(r);
+ const initialNotes: NotesRead = {
+ subject: { kind: "article", site: site.siteId, report: reportId },
+ file: corpusNotesFile(site.siteId, reportId) ?? "",
+ token: notes.token,
+ doc: notes.doc,
+ source: notes.doc?.source ?? source ?? null,
+ ...(notes.error ? { error: notes.error } : {}),
+ };
+ const status = sp.status === "resolved" || sp.status === "all" ? sp.status : "open";
+ const videoSlot = r.video ? (
+ <ArticleVideo src={media(r.video.src)} poster={r.video.poster ? media(r.video.poster) : undefined} caption={r.video.caption} />
+ ) : null;
+
+ return (
+ <div className="flex h-full flex-col">
+ {header}
+ <ArticleReader
+ site={site.siteId}
+ report={reportId}
+ view={view}
+ viewError={error}
+ initialNotes={initialNotes}
+ initialFilter={status}
+ initialNote={sp.note ?? null}
+ meta={meta}
+ videoSlot={videoSlot}
+ />
+ </div>
+ );
+}
diff --git a/umtool/components/articles/ArticleBody.tsx b/umtool/components/articles/ArticleBody.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { createContext, memo, useContext, type AnchorHTMLAttributes, type ReactNode } from "react";
+import { Markdown } from "yt-dlp-transcript-common/components/Markdown";
+import type { CitationView, ReportPageView } from "yt-dlp-transcript-common/lib/report/views";
+
+// The article's text, as blocks the notes anchor to (`data-block`: title,
+// subtitle, summary, method, each section by id). MEMOISED and never
+// re-rendered once mounted: the reader wraps notes' quotes in <mark>s by
+// editing this DOM directly (components/articles/anchorDom.ts), which is safe
+// only because React has no reason to touch it again. Everything that changes
+// -- which citation is open, which note is selected -- goes through the
+// context below, whose value never changes.
+//
+// A citation is a button (its label, which is part of the text) and its
+// number (which is not: `data-anchor-skip`). Clicking it opens the evidence
+// panel; nothing here links out of umtool.
+
+export type ArticleActions = {
+ openCite: (id: string) => void;
+ noteSection: (section: string, title: string) => void;
+};
+
+export const ArticleActionsContext = createContext<ArticleActions | null>(null);
+
+const CitationsContext = createContext<Readonly<Record<string, CitationView>>>({});
+
+const CITE_SCHEME = "cite:";
+
+function CiteLink({ href, children }: AnchorHTMLAttributes<HTMLAnchorElement>) {
+ const actions = useContext(ArticleActionsContext);
+ const citations = useContext(CitationsContext);
+ const id = href?.startsWith(CITE_SCHEME) ? href.slice(CITE_SCHEME.length).trim() : null;
+ if (id === null) {
+ return (
+ <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--color-sel)] underline decoration-[var(--color-sel)]/40">
+ {children}
+ </a>
+ );
+ }
+ const c = citations[id];
+ return (
+ <>
+ <button
+ type="button"
+ data-cite={id}
+ onClick={() => actions?.openCite(id)}
+ title={c ? `${c.quote}${c.speaker ? ` — ${c.speaker}` : ""}` : `citation ${id}`}
+ className="cursor-pointer rounded-sm text-left text-[var(--color-text)] underline decoration-[var(--color-sel)] decoration-dotted underline-offset-2 hover:bg-[var(--color-panel-2)] data-[has-note=true]:bg-[color-mix(in_srgb,var(--color-dirty)_22%,transparent)]"
+ >
+ {children}
+ </button>
+ <sup data-anchor-skip className="ml-0.5 font-mono text-[10px] text-[var(--color-sel)]">
+ {c?.number ?? "?"}
+ </sup>
+ </>
+ );
+}
+
+// markdown-to-jsx's elements carry common's class names, which umtool's
+// stylesheet does not have; these descendant rules are umtool's.
+const MD =
+ "text-[14px] leading-relaxed text-[var(--color-text)] [&_p]:my-2.5 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-[var(--color-line)] [&_blockquote]:pl-3 [&_blockquote]:text-[var(--color-dim)] [&_strong]:font-semibold [&_em]:italic [&_h3]:mt-4 [&_h3]:font-semibold [&_h4]:mt-3 [&_h4]:font-semibold [&_code]:font-mono [&_code]:text-[12px] [&_hr]:my-4 [&_hr]:border-[var(--color-line)]";
+
+function Md({ text }: { text: string }) {
+ return (
+ <Markdown className={MD} linkComponent={CiteLink}>
+ {text}
+ </Markdown>
+ );
+}
+
+function NoteButton({ section, title }: { section: string; title: string }) {
+ const actions = useContext(ArticleActionsContext);
+ return (
+ <button
+ type="button"
+ data-anchor-skip
+ aria-label={`note on ${title}`}
+ onClick={() => actions?.noteSection(section, title)}
+ className="ml-2 align-middle text-[11px] font-normal text-[var(--color-dim)] opacity-60 hover:text-[var(--color-sel)] hover:opacity-100"
+ >
+ + note
+ </button>
+ );
+}
+
+function ArticleBodyInner({ view, video }: { view: ReportPageView; video?: ReactNode }) {
+ return (
+ <CitationsContext.Provider value={view.citations}>
+ <div data-article-body className="space-y-5">
+ <header className="space-y-1">
+ {view.series && <div className="micro">{view.series}</div>}
+ <h1 data-block="title" className="text-[22px] font-semibold leading-tight text-[var(--color-text)]">
+ {view.title}
+ </h1>
+ {view.subtitle && (
+ <p data-block="subtitle" className="text-[15px] text-[var(--color-dim)]">
+ {view.subtitle}
+ </p>
+ )}
+ </header>
+ {/* The report's own video, under its title as the published page has
+ it. Outside every data-block, so it is never part of a quote. */}
+ {video}
+ {view.summary && (
+ <section data-block="summary" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-4 py-2">
+ <div className="micro pt-1" data-anchor-skip>
+ summary
+ <NoteButton section="summary" title="summary" />
+ </div>
+ <Md text={view.summary} />
+ </section>
+ )}
+ {view.sections.map((s) => (
+ <section key={s.id} id={`s-${s.id}`} data-block={s.id} className="scroll-mt-4">
+ <h2 className="mt-2 text-[17px] font-semibold text-[var(--color-text)]">
+ {s.title}
+ <NoteButton section={s.id} title={s.title} />
+ </h2>
+ {s.body && <Md text={s.body} />}
+ {s.claims.map((cl) => (
+ <div key={cl.id} data-claim={cl.id} className="my-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ {cl.title && <div className="text-[13px] font-semibold text-[var(--color-text)]">{cl.title}</div>}
+ <div className="flex items-start gap-2">
+ <div className="min-w-0 flex-1">
+ <Md text={cl.text} />
+ </div>
+ {cl.verdict && (
+ <span data-anchor-skip className="mt-2 shrink-0 rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[10px] uppercase text-[var(--color-dim)]">
+ {view.verdicts?.[cl.verdict]?.label ?? cl.verdict}
+ </span>
+ )}
+ </div>
+ {cl.findings && <Md text={cl.findings} />}
+ {cl.citations.length > 0 && (
+ <div data-anchor-skip className="mt-1 flex flex-wrap gap-1">
+ {cl.citations.map((id) => (
+ <CiteChip key={id} id={id} />
+ ))}
+ </div>
+ )}
+ </div>
+ ))}
+ </section>
+ ))}
+ {view.method && (
+ <section data-block="method" className="border-t border-[var(--color-line)] pt-3">
+ <div className="micro" data-anchor-skip>
+ method
+ <NoteButton section="method" title="method" />
+ </div>
+ <Md text={view.method} />
+ </section>
+ )}
+ </div>
+ </CitationsContext.Provider>
+ );
+}
+
+function CiteChip({ id }: { id: string }) {
+ const actions = useContext(ArticleActionsContext);
+ const c = useContext(CitationsContext)[id];
+ return (
+ <button
+ type="button"
+ data-cite={id}
+ onClick={() => actions?.openCite(id)}
+ title={c?.quote ?? id}
+ className="rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-sel)] hover:border-[var(--color-sel)] data-[has-note=true]:border-[var(--color-dirty)]"
+ >
+ {c?.number ?? "?"} {c?.label ?? c?.speaker ?? id}
+ </button>
+ );
+}
+
+const ArticleBody = memo(ArticleBodyInner, (a, b) => a.view === b.view && a.video === b.video);
+export default ArticleBody;
diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views";
+import CopyButton from "@/components/CopyButton";
+import { locateQuote, quoteAnchor } from "@/lib/annotations/anchor.mjs";
+import type { Anchor, Note, NotesRead } from "@/lib/annotations/types";
+import { useNotes } from "@/lib/annotations/useNotes";
+import ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody";
+import { blockText, selectionIn, unwrapMarks, wrapRange } from "./anchorDom";
+import EvidencePanel, { useEvidence } from "./EvidencePanel";
+import { Composer, NoteCard, anchorLabel } from "./NoteCards";
+
+// The article, its notes, and its evidence, in one screen: the text in a
+// ~70ch column, the notes in a rail beside it (a drawer below 1100px).
+//
+// select text → "Note" (or `n`) a note on that quote
+// "+ note" on a heading a note on the section
+// a citation its evidence in the rail; a note on it there
+// "+ whole article" a note on all of it
+//
+// Notes on a quote are found again in the text as it is NOW (lib/annotations/
+// anchor.mjs: the quote, then its context) and marked; one whose quote is gone
+// is still listed, flagged "orphaned", under its section.
+//
+// Keys (not while typing): j/k move between notes, r resolves, e edits, n
+// notes the selection (or the whole article), Esc closes whatever is open.
+
+type Filter = "open" | "resolved" | "all";
+
+const MARK_CLASS =
+ "rounded-sm bg-[color-mix(in_srgb,var(--color-sel)_22%,transparent)] text-inherit data-[active=true]:bg-[color-mix(in_srgb,var(--color-sel)_48%,transparent)] data-[status=resolved]:bg-transparent data-[status=resolved]:underline data-[status=resolved]:decoration-[var(--color-good)] data-[status=wontfix]:bg-transparent";
+
+type ComposerState = { anchor: Anchor; label: string } | null;
+
+export default function ArticleReader({
+ site,
+ report,
+ view,
+ initialNotes,
+ initialFilter = "open",
+ initialNote = null,
+ meta,
+ videoSlot,
+ viewError,
+}: {
+ site: string;
+ report: string;
+ view: ReportPageView;
+ initialNotes: NotesRead;
+ initialFilter?: Filter;
+ initialNote?: string | null;
+ meta: ReactNode;
+ videoSlot?: ReactNode;
+ viewError?: string | null;
+}) {
+ const notesApi = useNotes({ article: `${site}/${report}` }, { initial: initialNotes });
+ const { notes, write, busy, error } = notesApi;
+ const [filter, setFilter] = useState<Filter>(initialFilter);
+ const [selected, setSelected] = useState<string | null>(initialNote);
+ const [editing, setEditing] = useState<string | null>(null);
+ const [composer, setComposer] = useState<ComposerState>(null);
+ const [pending, setPending] = useState<{ anchor: Anchor; label: string; x: number; y: number } | null>(null);
+ const [cite, setCite] = useState<string | null>(null);
+ const [orphans, setOrphans] = useState<Set<string>>(new Set());
+ const [hover, setHover] = useState<string | null>(null);
+ const [drawer, setDrawer] = useState(false);
+ const bodyRef = useRef<HTMLDivElement>(null);
+ const evidence = useEvidence(site, report, cite);
+
+ const titles = useMemo(() => {
+ const t: Record<string, string> = { title: "title", subtitle: "subtitle", summary: "summary", method: "method" };
+ for (const s of view.sections) t[s.id] = s.title;
+ return t;
+ }, [view]);
+ const numbers = useMemo(() => Object.fromEntries(Object.entries(view.citations).map(([id, c]) => [id, c.number])), [view]);
+ const blockOrder = useMemo(() => ["title", "subtitle", "summary", ...view.sections.map((s) => s.id), "method"], [view]);
+
+ const visible = useMemo(() => {
+ const keep = (n: Note) => filter === "all" || (filter === "open" ? n.status === "open" : n.status !== "open");
+ const rank = (n: Note) => {
+ const a = n.anchor;
+ if (a.kind === "whole") return -1;
+ if (a.kind === "text" || a.kind === "section") return blockOrder.indexOf(a.section) + 0.5;
+ if (a.kind === "cite") return 1000 + (numbers[a.cite] ?? 0);
+ return 2000;
+ };
+ return notes.filter(keep).sort((a, b) => rank(a) - rank(b) || a.at.localeCompare(b.at));
+ }, [notes, filter, blockOrder, numbers]);
+
+ // ---- marks ---------------------------------------------------------------
+ useLayoutEffect(() => {
+ const root = bodyRef.current;
+ if (!root) return;
+ unwrapMarks(root);
+ const lost = new Set<string>();
+ for (const n of visible) {
+ const a = n.anchor;
+ if (a.kind !== "text") continue;
+ const block = root.querySelector(`[data-block="${CSS.escape(a.section)}"]`);
+ if (!block) {
+ lost.add(n.id);
+ continue;
+ }
+ const at = locateQuote(blockText(block).text, a);
+ if (!at.found) {
+ lost.add(n.id);
+ continue;
+ }
+ wrapRange(block, at.start, at.end, { "data-note": n.id, "data-status": n.status }, MARK_CLASS);
+ }
+ // Citation notes: mark the citation itself.
+ for (const el of Array.from(root.querySelectorAll("[data-cite][data-has-note]"))) el.removeAttribute("data-has-note");
+ for (const n of visible) {
+ if (n.anchor.kind === "cite") {
+ for (const el of Array.from(root.querySelectorAll(`[data-cite="${CSS.escape(n.anchor.cite)}"]`))) el.setAttribute("data-has-note", "true");
+ }
+ }
+ setOrphans((prev) => (prev.size === lost.size && [...lost].every((id) => prev.has(id)) ? prev : lost));
+ }, [visible]);
+
+ // The selected / hovered note's marks light up.
+ useEffect(() => {
+ const root = bodyRef.current;
+ if (!root) return;
+ for (const m of Array.from(root.querySelectorAll("mark[data-note]"))) {
+ const id = m.getAttribute("data-note");
+ m.setAttribute("data-active", String(id === selected || id === hover));
+ }
+ }, [selected, hover, visible]);
+
+ const focusNote = useCallback((id: string, scroll = true) => {
+ setSelected(id);
+ if (!scroll) return;
+ requestAnimationFrame(() => {
+ const mark = bodyRef.current?.querySelector(`mark[data-note="${CSS.escape(id)}"]`);
+ mark?.scrollIntoView({ block: "center", behavior: "smooth" });
+ document.querySelector(`[data-note-card="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
+ });
+ }, []);
+
+ // A deep link (`?note=`) lands on its note, whatever the filter says.
+ useEffect(() => {
+ if (!initialNote) return;
+ const n = notes.find((x) => x.id === initialNote);
+ if (n && filter !== "all" && (filter === "open") !== (n.status === "open")) setFilter("all");
+ focusNote(initialNote);
+ // once, on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // ---- selection → "Note" ---------------------------------------------------
+ const readSelection = useCallback(() => {
+ const root = bodyRef.current;
+ if (!root) return;
+ const sel = selectionIn(root);
+ if (!sel) {
+ setPending(null);
+ return;
+ }
+ const section = sel.block.getAttribute("data-block") ?? "";
+ const q = quoteAnchor(blockText(sel.block).text, sel.start, sel.end);
+ if (!q.quote) {
+ setPending(null);
+ return;
+ }
+ const anchor: Anchor = { kind: "text", section, ...q };
+ setPending({ anchor, label: anchorLabel(anchor, titles, numbers), x: sel.rect.right, y: sel.rect.top });
+ }, [titles, numbers]);
+
+ const openComposer = useCallback((anchor: Anchor) => {
+ setComposer({ anchor, label: anchorLabel(anchor, titles, numbers) });
+ setPending(null);
+ setDrawer(true);
+ window.getSelection()?.removeAllRanges();
+ }, [titles, numbers]);
+
+ // The body's buttons talk to the reader through a context whose value never
+ // changes (so the memoised body never re-renders): it reads the latest
+ // callbacks from a ref.
+ const latest = useRef({ setCite, openComposer, setDrawer });
+ latest.current = { setCite, openComposer, setDrawer };
+ const actions = useMemo<ArticleActions>(
+ () => ({
+ openCite: (id) => {
+ latest.current.setCite(id);
+ latest.current.setDrawer(true);
+ },
+ noteSection: (section) => latest.current.openComposer({ kind: "section", section }),
+ }),
+ [],
+ );
+
+ const add = async (anchor: Anchor, text: string) => {
+ const note = await write({ op: "add", text, anchor });
+ if (note) {
+ setComposer(null);
+ if (filter === "resolved") setFilter("open");
+ focusNote(note.id, false);
+ }
+ };
+
+ // ---- keys -----------------------------------------------------------------
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const t = e.target as HTMLElement | null;
+ if (t && (t.closest("input, textarea, select, [contenteditable=true]") || e.metaKey || e.ctrlKey || e.altKey)) return;
+ const idx = visible.findIndex((n) => n.id === selected);
+ if (e.key === "j" || e.key === "k") {
+ if (!visible.length) return;
+ e.preventDefault();
+ const next = e.key === "j" ? (idx < 0 ? 0 : Math.min(visible.length - 1, idx + 1)) : idx < 0 ? 0 : Math.max(0, idx - 1);
+ focusNote(visible[next].id);
+ } else if (e.key === "r" && idx >= 0) {
+ e.preventDefault();
+ void write({ op: "status", id: visible[idx].id, status: "resolved" });
+ } else if (e.key === "e" && idx >= 0 && visible[idx].author === "operator") {
+ e.preventDefault();
+ setEditing(visible[idx].id);
+ } else if (e.key === "n") {
+ e.preventDefault();
+ if (pending) openComposer(pending.anchor);
+ else if (cite) openComposer({ kind: "cite", cite });
+ else openComposer({ kind: "whole" });
+ } else if (e.key === "Escape") {
+ setComposer(null);
+ setEditing(null);
+ setPending(null);
+ setCite(null);
+ setDrawer(false);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [visible, selected, pending, cite, write, focusNote, openComposer]);
+
+ const counts = {
+ open: notes.filter((n) => n.status === "open").length,
+ resolved: notes.filter((n) => n.status !== "open").length,
+ all: notes.length,
+ };
+ const citeNotes = cite ? notes.filter((n) => n.anchor.kind === "cite" && n.anchor.cite === cite) : [];
+ const card = (n: Note) => (
+ <NoteCard
+ key={n.id}
+ note={n}
+ label={anchorLabel(n.anchor, titles, numbers)}
+ orphaned={orphans.has(n.id)}
+ selected={selected === n.id}
+ editing={editing === n.id}
+ busy={busy}
+ onSelect={() => focusNote(n.id)}
+ onHover={(on) => setHover(on ? n.id : null)}
+ onEdit={() => setEditing(n.id)}
+ onCancelEdit={() => setEditing(null)}
+ write={write}
+ />
+ );
+
+ return (
+ <div className="flex min-h-0 flex-1">
+ <main className="deck-main min-w-0 flex-1 p-4">
+ <div className="mx-auto max-w-[72ch] space-y-4">
+ {meta}
+ {viewError && <p className="text-[12px] text-[var(--color-bad)]">citations not shown: {viewError}</p>}
+ <ArticleActionsContext.Provider value={actions}>
+ <div
+ ref={bodyRef}
+ onMouseUp={() => setTimeout(readSelection, 0)}
+ onKeyUp={(e) => e.shiftKey && readSelection()}
+ onMouseOver={(e) => {
+ const m = (e.target as HTMLElement).closest?.("mark[data-note]");
+ setHover(m ? m.getAttribute("data-note") : null);
+ }}
+ onClick={(e) => {
+ const m = (e.target as HTMLElement).closest?.("mark[data-note]");
+ const id = m?.getAttribute("data-note");
+ if (id && window.getSelection()?.isCollapsed) {
+ setDrawer(true);
+ focusNote(id, false);
+ document.querySelector(`[data-note-card="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
+ }
+ }}
+ >
+ <ArticleBody view={view} video={videoSlot} />
+ </div>
+ </ArticleActionsContext.Provider>
+ </div>
+ </main>
+
+ {pending && (
+ <button
+ type="button"
+ onMouseDown={(e) => e.preventDefault()}
+ onClick={() => openComposer(pending.anchor)}
+ style={{ left: Math.min(pending.x + 6, window.innerWidth - 70), top: Math.max(8, pending.y - 30) }}
+ className="fixed z-50 rounded bg-[var(--color-sel)] px-2 py-0.5 text-[12px] font-medium text-[var(--color-ink)] shadow"
+ >
+ Note
+ </button>
+ )}
+
+ <button
+ type="button"
+ onClick={() => setDrawer((d) => !d)}
+ className="fixed bottom-3 right-3 z-30 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-2 py-1 text-[12px] min-[1100px]:hidden"
+ >
+ notes {counts.open}
+ </button>
+
+ <aside
+ aria-label="notes"
+ data-drawer={drawer ? "open" : "closed"}
+ className={`fixed inset-y-0 right-0 z-40 flex w-[min(380px,92vw)] flex-col border-l border-[var(--color-line)] bg-[var(--color-ink)] transition-transform min-[1100px]:static min-[1100px]:z-auto min-[1100px]:w-[360px] min-[1100px]:shrink-0 min-[1100px]:translate-x-0 ${drawer ? "translate-x-0" : "translate-x-full"}`}
+ >
+ <div className="flex flex-wrap items-center gap-1 border-b border-[var(--color-line)] p-2">
+ {(["open", "resolved", "all"] as Filter[]).map((f) => (
+ <button
+ key={f}
+ type="button"
+ aria-pressed={filter === f}
+ onClick={() => setFilter(f)}
+ className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${filter === f ? "border-[var(--color-sel)] text-[var(--color-sel)]" : "border-[var(--color-line)] text-[var(--color-dim)]"}`}
+ >
+ {f} {counts[f]}
+ </button>
+ ))}
+ <button
+ type="button"
+ onClick={() => openComposer({ kind: "whole" })}
+ className="ml-auto text-[11px] text-[var(--color-dim)] hover:text-[var(--color-sel)]"
+ >
+ + whole article
+ </button>
+ <button type="button" aria-label="close notes" onClick={() => setDrawer(false)} className="text-[12px] text-[var(--color-dim)] min-[1100px]:hidden">
+ ✕
+ </button>
+ </div>
+ <div className="flex items-center gap-2 border-b border-[var(--color-line)] px-2 py-1">
+ <CopyButton
+ url={`/api/notes/context?${new URLSearchParams({ article: `${site}/${report}` })}`}
+ label="copy agent brief"
+ title={`umtool notes ${site}/${report}`}
+ className="shrink-0 whitespace-nowrap"
+ />
+ {notesApi.source?.draft && (
+ <span className="truncate font-mono text-[10px] text-[var(--color-dim)]" title={notesApi.source.how ?? ""}>
+ edit {notesApi.source.draft}
+ </span>
+ )}
+ </div>
+ <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
+ {error && <p className="text-[12px] text-[var(--color-bad)]">{error}</p>}
+
+ {cite && (
+ <section data-evidence-rail className="space-y-2 rounded border border-[var(--color-line)] p-2">
+ <div className="flex items-center">
+ <span className="micro">citation [{numbers[cite] ?? cite}]</span>
+ <a href={`/sites/${site}/${report}/evidence?c=${encodeURIComponent(cite)}`} className="ml-auto text-[11px] text-[var(--color-dim)] hover:text-[var(--color-sel)]">
+ walk →
+ </a>
+ <button type="button" aria-label="close evidence" onClick={() => setCite(null)} className="ml-2 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]">
+ ✕
+ </button>
+ </div>
+ <EvidencePanel ev={evidence.ev} error={evidence.error} number={numbers[cite]} />
+ {citeNotes.length > 0 && <ul className="space-y-2">{citeNotes.map(card)}</ul>}
+ {composer?.anchor.kind === "cite" && composer.anchor.cite === cite ? (
+ <Composer label={composer.label} busy={busy} onSave={(text) => add(composer.anchor, text)} onCancel={() => setComposer(null)} />
+ ) : (
+ <button type="button" onClick={() => openComposer({ kind: "cite", cite })} className="text-[11px] text-[var(--color-dim)] hover:text-[var(--color-sel)]">
+ + note on this citation
+ </button>
+ )}
+ </section>
+ )}
+
+ {composer && !(composer.anchor.kind === "cite" && composer.anchor.cite === cite) && (
+ <Composer label={composer.label} busy={busy} onSave={(text) => add(composer.anchor, text)} onCancel={() => setComposer(null)} />
+ )}
+
+ {visible.length === 0 ? (
+ <p className="text-[12px] text-[var(--color-dim)]">{notes.length === 0 ? "no notes" : `no ${filter} notes`}</p>
+ ) : (
+ <ul data-notes-list className="space-y-2">
+ {/* A note on the open citation is listed with its evidence above. */}
+ {visible.filter((n) => !(cite && n.anchor.kind === "cite" && n.anchor.cite === cite)).map(card)}
+ </ul>
+ )}
+ </div>
+ </aside>
+ </div>
+ );
+}
diff --git a/umtool/components/articles/ArticleVideo.tsx b/umtool/components/articles/ArticleVideo.tsx
@@ -0,0 +1,13 @@
+// THE ARTICLE'S OWN VIDEO SLOT -- the report's video.mp4, as the published page
+// plays it. A plain <video> for now; the integration swaps in the shared timed
+// notes player (components/notes/TimedNotes.tsx, release track B) here, with
+// moment anchors `{ kind: "moment", file: "video.mp4", t }` on the article's
+// notes. Keep this the only place the article page renders its video.
+export default function ArticleVideo({ src, poster, caption }: { src: string; poster?: string; caption?: string }) {
+ return (
+ <figure data-article-video className="space-y-1">
+ <video controls preload="metadata" src={src} poster={poster} className="aspect-video w-full rounded bg-black" />
+ {caption && <figcaption className="text-[12px] text-[var(--color-dim)]">{caption}</figcaption>}
+ </figure>
+ );
+}
diff --git a/umtool/components/articles/EvidencePanel.tsx b/umtool/components/articles/EvidencePanel.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
+import CopyButton from "@/components/CopyButton";
+import type { Evidence } from "@/lib/articles/evidence";
+
+// One citation's evidence: what it quotes, who and when, the transcript around
+// it with the cited cues marked, and the media that plays it -- the prepared
+// clip, a fetched window or a saved file, seeked to the cited second -- or,
+// when there is none on disk, the MCP line that fetches it through the editor.
+
+export type EvidenceHandle = { togglePlay: () => void };
+
+const fmt = (s: number) => {
+ const m = Math.floor(s / 60);
+ const r = Math.floor(s % 60);
+ return `${m}:${String(r).padStart(2, "0")}`;
+};
+
+export function useEvidence(site: string, report: string, cite: string | null) {
+ const [ev, setEv] = useState<Evidence | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ useEffect(() => {
+ if (!cite) return;
+ let live = true;
+ setEv(null);
+ setError(null);
+ fetch(`/api/sites/evidence?${new URLSearchParams({ site, report, cite })}`, { cache: "no-store" })
+ .then(async (r) => {
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error ?? r.statusText);
+ if (live) setEv(j);
+ })
+ .catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
+ return () => {
+ live = false;
+ };
+ }, [site, report, cite]);
+ return { ev, error };
+}
+
+const EvidencePanel = forwardRef<EvidenceHandle, { ev: Evidence | null; error: string | null; number?: number; autoPlay?: boolean }>(
+ function EvidencePanel({ ev, error, number, autoPlay = false }, ref) {
+ const media = useRef<HTMLMediaElement | null>(null);
+ useImperativeHandle(ref, () => ({
+ togglePlay: () => {
+ const m = media.current;
+ if (!m) return;
+ if (m.paused) void m.play().catch(() => {});
+ else m.pause();
+ },
+ }));
+
+ if (error) return <p className="text-[12px] text-[var(--color-bad)]">{error}</p>;
+ if (!ev) return <p className="text-[12px] text-[var(--color-dim)]">loading…</p>;
+
+ const play = ev.play;
+ // File time of a record second: the cited span starts `offset` into the file.
+ const fileT = (t: number) => (play && ev.start !== undefined ? Math.max(0, t - ev.start + play.offset) : 0);
+ const startAt = play ? (play.kind === "prepared" ? 0 : Math.max(0, play.offset - 3)) : 0;
+ const seek = (t: number) => {
+ if (media.current) {
+ media.current.currentTime = fileT(t);
+ void media.current.play().catch(() => {});
+ }
+ };
+
+ return (
+ <div data-evidence={ev.cite} className="space-y-2 text-[12px]">
+ <blockquote className="border-l-2 border-[var(--color-sel)] pl-2 text-[13px] text-[var(--color-text)]">
+ {number !== undefined && <span className="mr-1 font-mono text-[11px] text-[var(--color-sel)]">[{number}]</span>}“{ev.quote}”
+ </blockquote>
+ <div className="text-[var(--color-dim)]">
+ {[ev.speaker ?? ev.record?.channelTitle, ev.date, ev.label].filter(Boolean).join(" · ")}
+ {ev.start !== undefined && ev.end !== undefined && (
+ <span className="num ml-1">
+ @ {fmt(ev.start)}–{fmt(ev.end)}
+ </span>
+ )}
+ </div>
+ {ev.record?.title && <div className="text-[var(--color-dim)]">{ev.record.title}</div>}
+ {ev.originalUrl && (
+ <a href={ev.originalUrl} target="_blank" rel="noopener noreferrer" className="text-[var(--color-sel)] hover:underline">
+ original ↗
+ </a>
+ )}
+
+ {play ? (
+ <div data-play={play.kind}>
+ {play.audio ? (
+ <audio
+ ref={(el) => {
+ media.current = el;
+ }}
+ controls
+ src={play.url}
+ autoPlay={autoPlay}
+ onLoadedMetadata={(e) => {
+ e.currentTarget.currentTime = startAt;
+ }}
+ className="w-full"
+ />
+ ) : (
+ <video
+ ref={(el) => {
+ media.current = el;
+ }}
+ controls
+ src={play.url}
+ autoPlay={autoPlay}
+ onLoadedMetadata={(e) => {
+ e.currentTarget.currentTime = startAt;
+ }}
+ className="aspect-video w-full rounded bg-black"
+ />
+ )}
+ <div className="micro mt-0.5">{play.label}</div>
+ </div>
+ ) : ev.fetchLine ? (
+ <div data-play="none" className="space-y-1 rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] p-2">
+ <div className="text-[var(--color-dim)]">not on disk — fetch it through the editor:</div>
+ <code className="block break-all font-mono text-[11px] text-[var(--color-text)]">{ev.fetchLine}</code>
+ <CopyButton text={ev.fetchLine} label="copy fetch_clip" />
+ </div>
+ ) : null}
+
+ {ev.post && (
+ <div data-post className="space-y-1 rounded border border-[var(--color-line)] p-2">
+ {ev.post.author && <div className="text-[var(--color-dim)]">{ev.post.author}</div>}
+ {ev.post.text && <p className="whitespace-pre-wrap text-[var(--color-text)]">{ev.post.text}</p>}
+ {ev.post.shot && (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img src={ev.post.shot} alt="capture of the post" className="w-full rounded border border-[var(--color-line)]" />
+ )}
+ </div>
+ )}
+
+ {ev.cues.length > 0 ? (
+ <ol data-cues className="space-y-0.5">
+ {ev.cues.map((c) => (
+ <li key={c.start} data-cited={c.cited ? "true" : undefined}>
+ <button
+ type="button"
+ onClick={() => seek(c.start)}
+ disabled={!play}
+ className={`flex w-full gap-2 rounded px-1 text-left ${c.cited ? "bg-[var(--color-panel-2)] text-[var(--color-text)]" : "text-[var(--color-dim)]"} enabled:hover:text-[var(--color-text)]`}
+ >
+ <span className="num shrink-0 font-mono text-[10px] leading-5">{fmt(c.start)}</span>
+ <span>{c.text}</span>
+ </button>
+ </li>
+ ))}
+ </ol>
+ ) : ev.cuesNote ? (
+ <div className="micro">{ev.cuesNote}</div>
+ ) : null}
+ </div>
+ );
+ },
+);
+
+export default EvidencePanel;
diff --git a/umtool/components/articles/EvidenceWalk.tsx b/umtool/components/articles/EvidenceWalk.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { NotesRead } from "@/lib/annotations/types";
+import { useNotes } from "@/lib/annotations/useNotes";
+import EvidencePanel, { useEvidence, type EvidenceHandle } from "./EvidencePanel";
+import { Composer, NoteCard } from "./NoteCards";
+
+// Every citation of one article, one per screen: the evidence on the left, its
+// notes on the right. j/k (or ←/→) move, space plays, n notes it, Esc cancels.
+// The citation in view is in the URL (`?c=`), so a walk can be resumed.
+
+export type WalkItem = { id: string; number: number; kind: string; quote: string };
+
+export default function EvidenceWalk({
+ site,
+ report,
+ items,
+ initial,
+ initialNotes,
+}: {
+ site: string;
+ report: string;
+ items: WalkItem[];
+ initial: number;
+ initialNotes: NotesRead;
+}) {
+ const [idx, setIdx] = useState(initial);
+ const [composing, setComposing] = useState(false);
+ const item = items[idx];
+ const { ev, error } = useEvidence(site, report, item?.id ?? null);
+ const { notes, write, busy, error: notesError } = useNotes({ article: `${site}/${report}` }, { initial: initialNotes });
+ const panel = useRef<EvidenceHandle>(null);
+
+ const go = useCallback(
+ (to: number) => {
+ const next = Math.max(0, Math.min(items.length - 1, to));
+ setIdx(next);
+ setComposing(false);
+ const url = new URL(window.location.href);
+ url.searchParams.set("c", items[next].id);
+ window.history.replaceState(null, "", url);
+ },
+ [items],
+ );
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const t = e.target as HTMLElement | null;
+ if (t?.closest("input, textarea, select, [contenteditable=true]") || e.metaKey || e.ctrlKey || e.altKey) return;
+ if (e.key === "j" || e.key === "ArrowRight") {
+ e.preventDefault();
+ go(idx + 1);
+ } else if (e.key === "k" || e.key === "ArrowLeft") {
+ e.preventDefault();
+ go(idx - 1);
+ } else if (e.key === " ") {
+ e.preventDefault();
+ panel.current?.togglePlay();
+ } else if (e.key === "n") {
+ e.preventDefault();
+ setComposing(true);
+ } else if (e.key === "Escape") {
+ setComposing(false);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [idx, go]);
+
+ if (!item) return <p className="p-4 text-[12px] text-[var(--color-dim)]">no citations</p>;
+ const mine = notes.filter((n) => n.anchor.kind === "cite" && n.anchor.cite === item.id);
+
+ return (
+ <div data-walk={item.id} className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-auto p-4 min-[1100px]:grid-cols-[minmax(0,1.3fr)_minmax(320px,1fr)]">
+ <div className="min-w-0 space-y-2">
+ <div className="flex items-center gap-2 text-[12px]">
+ <button type="button" onClick={() => go(idx - 1)} disabled={idx === 0} className="rounded border border-[var(--color-line)] px-2 disabled:opacity-30">
+ ← k
+ </button>
+ <span className="num" data-walk-position>
+ {idx + 1} / {items.length}
+ </span>
+ <button type="button" onClick={() => go(idx + 1)} disabled={idx === items.length - 1} className="rounded border border-[var(--color-line)] px-2 disabled:opacity-30">
+ j →
+ </button>
+ <span className="micro ml-2">{item.kind}</span>
+ <span className="micro ml-auto">space plays · n notes</span>
+ </div>
+ <EvidencePanel key={item.id} ref={panel} ev={ev} error={error} number={item.number} />
+ </div>
+ <div className="min-w-0 space-y-2">
+ <div className="micro">notes on [{item.number}]</div>
+ {notesError && <p className="text-[12px] text-[var(--color-bad)]">{notesError}</p>}
+ {mine.length > 0 && (
+ <ul className="space-y-2">
+ {mine.map((n) => (
+ <NoteCard key={n.id} note={n} label={`citation [${item.number}]`} busy={busy} write={write} />
+ ))}
+ </ul>
+ )}
+ {composing ? (
+ <Composer
+ label={`citation [${item.number}]`}
+ busy={busy}
+ onSave={async (text) => {
+ const note = await write({ op: "add", text, anchor: { kind: "cite", cite: item.id } });
+ if (note) setComposing(false);
+ }}
+ onCancel={() => setComposing(false)}
+ />
+ ) : (
+ <button type="button" onClick={() => setComposing(true)} className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-sel)]">
+ + note on this citation
+ </button>
+ )}
+ <div className="pt-2">
+ <Link href={`/sites/${site}/${report}`} className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]">
+ ← article
+ </Link>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/umtool/components/articles/NoteCards.tsx b/umtool/components/articles/NoteCards.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { NOTE_TEXT_LIMIT } from "@/lib/annotations/types";
+import type { Anchor, Note, NoteOp } from "@/lib/annotations/types";
+import { fmtAgo } from "@/lib/format";
+
+// A note in the rail, and the box that writes one. Shared by the article
+// reader and the evidence walk.
+
+export function anchorLabel(a: Anchor, titles: Record<string, string>, cites: Record<string, number | undefined>): string {
+ const sec = (id: string) => titles[id] ?? id;
+ switch (a.kind) {
+ case "text":
+ return `${sec(a.section)} · “${a.quote.length > 60 ? `${a.quote.slice(0, 59)}…` : a.quote}”`;
+ case "section":
+ return `section · ${sec(a.section)}`;
+ case "cite":
+ return `citation [${cites[a.cite] ?? a.cite}]`;
+ case "whole":
+ return "whole article";
+ case "moment":
+ return `${a.file} @ ${a.t.toFixed(1)}s`;
+ default:
+ return a.kind;
+ }
+}
+
+export function Composer({
+ label,
+ initial = "",
+ busy,
+ onSave,
+ onCancel,
+ saveLabel = "save note",
+}: {
+ label: string;
+ initial?: string;
+ busy?: boolean;
+ onSave: (text: string) => void | Promise<void>;
+ onCancel: () => void;
+ saveLabel?: string;
+}) {
+ const [text, setText] = useState(initial);
+ const ref = useRef<HTMLTextAreaElement>(null);
+ useEffect(() => ref.current?.focus(), []);
+ const save = () => {
+ if (text.trim()) void onSave(text);
+ };
+ return (
+ <div data-composer className="space-y-1 rounded border border-[var(--color-sel)] bg-[var(--color-panel)] p-2">
+ <div className="micro truncate" title={label}>
+ {label}
+ </div>
+ <textarea
+ ref={ref}
+ aria-label="note text"
+ value={text}
+ maxLength={NOTE_TEXT_LIMIT}
+ onChange={(e) => setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ save();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ }
+ }}
+ rows={4}
+ className="w-full resize-y rounded border border-[var(--color-line)] bg-[var(--color-ink)] p-1.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
+ />
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={save}
+ disabled={busy || !text.trim()}
+ className="rounded bg-[var(--color-sel)] px-2 py-0.5 text-[12px] text-[var(--color-ink)] disabled:opacity-40"
+ >
+ {saveLabel}
+ </button>
+ <button type="button" onClick={onCancel} className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]">
+ cancel
+ </button>
+ <span className="micro ml-auto">ctrl+enter</span>
+ </div>
+ </div>
+ );
+}
+
+const STATUS_TONE: Record<Note["status"], string> = {
+ open: "text-[var(--color-dirty)] border-[var(--color-dirty)]",
+ resolved: "text-[var(--color-good)] border-[var(--color-good)]",
+ wontfix: "text-[var(--color-dim)] border-[var(--color-line)]",
+};
+
+export function NoteCard({
+ note,
+ label,
+ orphaned,
+ selected,
+ editing,
+ busy,
+ onSelect,
+ onHover,
+ onEdit,
+ onCancelEdit,
+ write,
+}: {
+ note: Note;
+ label: string;
+ orphaned?: boolean;
+ selected?: boolean;
+ editing?: boolean;
+ busy?: boolean;
+ onSelect?: () => void;
+ onHover?: (on: boolean) => void;
+ onEdit?: () => void;
+ onCancelEdit?: () => void;
+ write: (op: NoteOp) => Promise<unknown>;
+}) {
+ const [replying, setReplying] = useState(false);
+ const mine = note.author === "operator";
+ return (
+ <li
+ data-note-card={note.id}
+ data-status={note.status}
+ data-orphaned={orphaned ? "true" : undefined}
+ aria-current={selected ? "true" : undefined}
+ onMouseEnter={() => onHover?.(true)}
+ onMouseLeave={() => onHover?.(false)}
+ onClick={onSelect}
+ className={`cursor-default space-y-1 rounded border bg-[var(--color-panel)] p-2 text-[12px] ${selected ? "border-[var(--color-sel)]" : "border-[var(--color-line)]"}`}
+ >
+ <div className="flex items-center gap-1.5">
+ <span className={`rounded border px-1 font-mono text-[10px] uppercase ${STATUS_TONE[note.status]}`}>{note.status}</span>
+ {note.author === "agent" && <span className="rounded border border-[var(--color-meter)] px-1 font-mono text-[10px] text-[var(--color-meter)]">agent</span>}
+ {orphaned && (
+ <span title="the quoted text is no longer in the article" className="rounded border border-[var(--color-bad)] px-1 font-mono text-[10px] text-[var(--color-bad)]">
+ orphaned
+ </span>
+ )}
+ <span className="num ml-auto text-[10px] text-[var(--color-dim)]" title={note.updatedAt}>
+ {fmtAgo(Date.parse(note.updatedAt))}
+ </span>
+ </div>
+ <div className="truncate text-[11px] text-[var(--color-dim)]" title={label}>
+ {label}
+ </div>
+ {editing ? (
+ <Composer
+ label="edit note"
+ initial={note.text}
+ busy={busy}
+ saveLabel="save"
+ onSave={async (text) => {
+ await write({ op: "edit", id: note.id, text });
+ onCancelEdit?.();
+ }}
+ onCancel={() => onCancelEdit?.()}
+ />
+ ) : (
+ <p data-note-text className="whitespace-pre-wrap text-[var(--color-text)]">
+ {note.text}
+ </p>
+ )}
+ {note.replies.length > 0 && (
+ <ul className="space-y-1 border-l border-[var(--color-line)] pl-2">
+ {note.replies.map((r, i) => (
+ <li key={`${r.at}-${i}`} data-reply={r.author} className="text-[12px]">
+ <span className={`mr-1 font-mono text-[10px] ${r.author === "agent" ? "text-[var(--color-meter)]" : "text-[var(--color-dim)]"}`}>{r.author}</span>
+ <span className="whitespace-pre-wrap text-[var(--color-text)]">{r.text}</span>
+ </li>
+ ))}
+ </ul>
+ )}
+ {replying ? (
+ <Composer
+ label="reply"
+ busy={busy}
+ saveLabel="reply"
+ onSave={async (text) => {
+ await write({ op: "reply", id: note.id, text });
+ setReplying(false);
+ }}
+ onCancel={() => setReplying(false)}
+ />
+ ) : (
+ <div className="flex flex-wrap gap-2 pt-0.5 text-[11px]">
+ {note.status === "open" ? (
+ <>
+ <Act onClick={() => write({ op: "status", id: note.id, status: "resolved" })} disabled={busy}>
+ resolve
+ </Act>
+ <Act onClick={() => write({ op: "status", id: note.id, status: "wontfix" })} disabled={busy}>
+ won’t fix
+ </Act>
+ </>
+ ) : (
+ <Act onClick={() => write({ op: "status", id: note.id, status: "open" })} disabled={busy}>
+ reopen
+ </Act>
+ )}
+ <Act onClick={() => setReplying(true)} disabled={busy}>
+ reply
+ </Act>
+ {mine && onEdit && (
+ <Act onClick={onEdit} disabled={busy}>
+ edit
+ </Act>
+ )}
+ <Act
+ onClick={() => {
+ if (window.confirm("Delete this note?")) void write({ op: "delete", id: note.id });
+ }}
+ disabled={busy}
+ >
+ delete
+ </Act>
+ </div>
+ )}
+ </li>
+ );
+}
+
+function Act({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: React.ReactNode }) {
+ return (
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ onClick();
+ }}
+ disabled={disabled}
+ className="text-[var(--color-dim)] hover:text-[var(--color-sel)] disabled:opacity-40"
+ >
+ {children}
+ </button>
+ );
+}
diff --git a/umtool/components/articles/anchorDom.ts b/umtool/components/articles/anchorDom.ts
@@ -0,0 +1,98 @@
+// The article's TEXT, as the notes see it, and the <mark>s that show them.
+//
+// A block (`[data-block]`: title, subtitle, summary, method, or a section) has
+// one plain text: its text nodes in document order, MINUS anything inside
+// `[data-anchor-skip]` -- a citation's superscript number, a "+ note" button --
+// so a quote never carries a "3" from a cite marker and re-anchors the same
+// way `umtool notes` reads the section from report.json. lib/annotations/
+// anchor.mjs locates a quote in that text; these turn offsets into DOM and back.
+//
+// The marks are DOM the app adds AFTER React has rendered the (memoised,
+// never re-rendered) article body, and removes before adding them again.
+
+export const SKIP = "[data-anchor-skip]";
+
+export type TextModel = { text: string; nodes: { node: Text; start: number }[] };
+
+export function blockText(root: Element): TextModel {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) => {
+ const skip = n.parentElement?.closest(SKIP);
+ return skip && root.contains(skip) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
+ },
+ });
+ const nodes: TextModel["nodes"] = [];
+ let text = "";
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
+ const t = n as Text;
+ nodes.push({ node: t, start: text.length });
+ text += t.data;
+ }
+ return { text, nodes };
+}
+
+/** The text offset of a DOM point inside `root`. */
+export function offsetOf(root: Element, container: Node, offset: number): number {
+ const { nodes, text } = blockText(root);
+ const point = document.createRange();
+ point.setStart(container, offset);
+ for (const { node, start } of nodes) {
+ if (node === container) return start + Math.min(offset, node.data.length);
+ const r = document.createRange();
+ r.selectNodeContents(node);
+ if (r.compareBoundaryPoints(Range.START_TO_START, point) >= 0) return start;
+ }
+ return text.length;
+}
+
+export function unwrapMarks(root: Element) {
+ const parents = new Set<Node>();
+ for (const m of Array.from(root.querySelectorAll("mark[data-note]"))) {
+ const p = m.parentNode;
+ if (!p) continue;
+ while (m.firstChild) p.insertBefore(m.firstChild, m);
+ p.removeChild(m);
+ parents.add(p);
+ }
+ for (const p of parents) p.normalize();
+}
+
+/** Wrap [start, end) of a block's text in marks carrying `attrs`. Returns the marks. */
+export function wrapRange(root: Element, start: number, end: number, attrs: Record<string, string>, className: string): HTMLElement[] {
+ const { nodes } = blockText(root);
+ const out: HTMLElement[] = [];
+ for (const { node, start: ns } of nodes) {
+ const ne = ns + node.data.length;
+ if (ne <= start || ns >= end) continue;
+ const a = Math.max(start, ns) - ns;
+ const b = Math.min(end, ne) - ns;
+ if (b <= a) continue;
+ let t: Text = node;
+ if (a > 0) t = t.splitText(a);
+ if (b - a < t.data.length) t.splitText(b - a);
+ // Whitespace between two block elements is not worth a mark.
+ if (!t.data.trim()) continue;
+ const mark = document.createElement("mark");
+ for (const [k, v] of Object.entries(attrs)) mark.setAttribute(k, v);
+ mark.className = className;
+ t.parentNode!.insertBefore(mark, t);
+ mark.appendChild(t);
+ out.push(mark);
+ }
+ return out;
+}
+
+/** The block a selection lies in, and its offsets, or null (collapsed, or across blocks). */
+export function selectionIn(container: Element): { block: Element; start: number; end: number; rect: DOMRect } | null {
+ const sel = window.getSelection();
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
+ const range = sel.getRangeAt(0);
+ const el = (n: Node) => (n.nodeType === Node.ELEMENT_NODE ? (n as Element) : n.parentElement);
+ const a = el(range.startContainer)?.closest("[data-block]");
+ const b = el(range.endContainer)?.closest("[data-block]");
+ if (!a || a !== b || !container.contains(a)) return null;
+ const start = offsetOf(a, range.startContainer, range.startOffset);
+ const end = offsetOf(a, range.endContainer, range.endOffset);
+ if (end <= start) return null;
+ return { block: a, start, end, rect: range.getBoundingClientRect() };
+}
diff --git a/umtool/e2e/article-evidence.spec.ts b/umtool/e2e/article-evidence.spec.ts
@@ -0,0 +1,64 @@
+import { test, expect } from "@playwright/test";
+import { rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// A citation's evidence: the transcript around it, the window that plays it,
+// or -- with nothing on disk -- the fetch_clip line; and the one-per-screen
+// walk.
+//
+// priv/polemic-alpha c1 → sitechan/sv1, a clip window on disk
+// c2 → sitechan/sv2, cues only
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json");
+
+test.beforeEach(() => rmSync(NOTES, { force: true }));
+test.afterAll(() => rmSync(NOTES, { force: true }));
+
+test("a citation opens its evidence: the cited cue marked, the window playing from the span", async ({ page }) => {
+ await page.goto("/sites/priv/polemic-alpha");
+ await page.locator("button[data-cite='c1']").first().click();
+ const ev = page.locator("[data-evidence='c1']");
+ await expect(ev).toContainText("The vote was rigged and everybody knew.");
+ await expect(ev.locator("[data-play]")).toHaveAttribute("data-play", "window");
+ await expect(ev.locator("[data-cited='true']")).toHaveCount(1);
+ await expect(ev.locator("[data-cited='true']")).toContainText("The vote was rigged");
+ await expect(ev.locator("[data-cues] li")).toHaveCount(6);
+ const video = ev.locator("video");
+ await expect(video).toHaveAttribute("src", /corpus=.*sv1.*clips/);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime)).toBeGreaterThanOrEqual(0);
+});
+
+test("with nothing on disk it says so and offers the fetch_clip line, never yt-dlp", async ({ page }) => {
+ await page.goto("/sites/priv/polemic-alpha");
+ await page.locator("button[data-cite='c2']").first().click();
+ const ev = page.locator("[data-evidence='c2']");
+ await expect(ev.locator("[data-play]")).toHaveAttribute("data-play", "none");
+ await expect(ev).toContainText('fetch_clip {"channel":"sitechan","video":"sv2","start":8,"end":12');
+ await expect(ev).not.toContainText("yt-dlp");
+});
+
+test("the walk: one citation per screen, j/k, n notes it, the URL follows", async ({ page }) => {
+ await page.setViewportSize({ width: 1366, height: 768 });
+ await page.goto("/sites/priv/polemic-alpha/evidence");
+ await expect(page.locator("[data-walk='c1']")).toBeVisible();
+ await expect(page.locator("[data-walk-position]")).toHaveText("1 / 2");
+ await page.keyboard.press("j");
+ await expect(page.locator("[data-walk='c2']")).toBeVisible();
+ await expect(page).toHaveURL(/\?c=c2$/);
+ await page.keyboard.press("n");
+ await page.getByLabel("note text").fill("Needs a clip.");
+ await page.keyboard.press("Control+Enter");
+ await expect(page.locator("[data-walk='c2'] [data-note-card]")).toHaveCount(1);
+ await page.keyboard.press("k");
+ await expect(page.locator("[data-walk='c1'] [data-note-card]")).toHaveCount(0);
+
+ // It fits the laptop screen: no horizontal scroll.
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+
+ await page.goto("/sites/priv/polemic-alpha/evidence?c=c2");
+ await expect(page.locator("[data-walk='c2'] [data-note-card]")).toContainText("Needs a clip.");
+});
diff --git a/umtool/e2e/article-notes.spec.ts b/umtool/e2e/article-notes.spec.ts
@@ -0,0 +1,179 @@
+import { test, expect, type Page } from "@playwright/test";
+import { existsSync, readFileSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// Notes on an article: written beside report.json (the one corpus file umtool
+// writes), anchored to a quote and found again, resolved, deleted -- and the
+// last delete removes the file.
+//
+// priv/polemic-alpha WRITES sites/priv/reports/polemic-alpha/notes.json
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json");
+const PAGE = "/sites/priv/polemic-alpha";
+
+test.beforeEach(() => rmSync(NOTES, { force: true }));
+test.afterAll(() => rmSync(NOTES, { force: true }));
+
+async function select(page: Page, block: string, text: string) {
+ await page.evaluate(
+ ([block, text]) => {
+ const root = document.querySelector(`[data-block="${block}"]`)!;
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ for (let n = w.nextNode() as Text | null; n; n = w.nextNode() as Text | null) {
+ const i = n.data.indexOf(text);
+ if (i < 0) continue;
+ const r = document.createRange();
+ r.setStart(n, i);
+ r.setEnd(n, i + text.length);
+ const s = getSelection()!;
+ s.removeAllRanges();
+ s.addRange(r);
+ return;
+ }
+ throw new Error(`no "${text}" in ${block}`);
+ },
+ [block, text],
+ );
+ await page.locator(`[data-block="${block}"]`).dispatchEvent("mouseup");
+}
+
+test("select text, Note, save: a mark on the quote, a note beside report.json", async ({ page }) => {
+ await page.goto(PAGE);
+ await select(page, "first", "Nobody checked the claim");
+ await page.getByRole("button", { name: "Note", exact: true }).click();
+ await page.getByLabel("note text").fill("Source this sentence.");
+ await page.getByRole("button", { name: "save note" }).click();
+
+ await expect(page.locator("mark[data-note]")).toHaveText("Nobody checked the claim");
+ const doc = JSON.parse(readFileSync(NOTES, "utf8"));
+ expect(doc.subject).toEqual({ kind: "article", site: "priv", report: "polemic-alpha" });
+ expect(doc.source.draft).toMatch(/sitews\/polemics\/drafts\/alpha\.json$/);
+ expect(doc.notes[0]).toMatchObject({ status: "open", author: "operator", text: "Source this sentence." });
+ expect(doc.notes[0].anchor).toMatchObject({ kind: "text", section: "first", quote: "Nobody checked the claim" });
+ expect(doc.notes[0].anchor.prefix).toContain("first stream.");
+
+ // Found again after a reload, and hovering the card lights the mark.
+ await page.reload();
+ const card = page.locator("[data-note-card]");
+ await expect(card).toHaveCount(1);
+ await card.hover();
+ await expect(page.locator("mark[data-note]")).toHaveAttribute("data-active", "true");
+});
+
+test("section, whole-article and citation notes; resolve, reopen, reply, filters", async ({ page }) => {
+ await page.goto(PAGE);
+ await page.getByRole("button", { name: "note on Later" }).click();
+ await page.getByLabel("note text").fill("Section note.");
+ await page.keyboard.press("Control+Enter");
+ await expect(page.locator("[data-note-card]")).toHaveCount(1);
+
+ await page.getByRole("button", { name: "+ whole article" }).click();
+ await page.getByLabel("note text").fill("Whole note.");
+ await page.getByRole("button", { name: "save note" }).click();
+ await expect(page.locator("[data-note-card]")).toHaveCount(2);
+
+ await page.locator("button[data-cite='c1']").first().click();
+ await expect(page.locator("[data-evidence='c1']")).toBeVisible();
+ await page.getByRole("button", { name: "+ note on this citation" }).click();
+ await page.getByLabel("note text").fill("Right second?");
+ await page.getByRole("button", { name: "save note" }).click();
+ await expect(page.locator("[data-evidence-rail] [data-note-card]")).toHaveCount(1);
+ await expect(page.locator("button[data-cite='c1'][data-has-note='true']").first()).toBeVisible();
+
+ const notes = JSON.parse(readFileSync(NOTES, "utf8")).notes;
+ expect(notes.map((n: { anchor: { kind: string } }) => n.anchor.kind).sort()).toEqual(["cite", "section", "whole"]);
+
+ // resolve the whole-article note; the open filter drops it
+ const whole = page.locator("[data-note-card]", { hasText: "Whole note." });
+ await whole.getByRole("button", { name: "resolve" }).click();
+ await expect(page.locator("[data-note-card]", { hasText: "Whole note." })).toHaveCount(0);
+ await page.getByRole("button", { name: /^resolved 1$/ }).click();
+ const resolved = page.locator("[data-note-card]", { hasText: "Whole note." });
+ await expect(resolved).toHaveAttribute("data-status", "resolved");
+ await resolved.getByRole("button", { name: "reopen" }).click();
+ await page.getByRole("button", { name: /^all 3$/ }).click();
+ const reopened = page.locator("[data-note-card]", { hasText: "Whole note." });
+ await expect(reopened).toHaveAttribute("data-status", "open");
+ await reopened.getByRole("button", { name: "reply" }).click();
+ await page.getByLabel("note text").fill("A reply.");
+ await reopened.getByRole("button", { name: "reply", exact: true }).click();
+ await expect(reopened.locator("[data-reply='operator']")).toContainText("A reply.");
+});
+
+test("keys: j selects, r resolves, n notes the selection", async ({ page }) => {
+ await page.goto(PAGE);
+ await select(page, "later", "on a different show");
+ await expect(page.getByRole("button", { name: "Note", exact: true })).toBeVisible();
+ await page.keyboard.press("n");
+ await page.getByLabel("note text").fill("Which show?");
+ await page.keyboard.press("Control+Enter");
+ await expect(page.locator("mark[data-note]")).toHaveText("on a different show");
+ await page.locator("main").click({ position: { x: 5, y: 5 } });
+ await page.keyboard.press("j");
+ await expect(page.locator("[data-note-card]")).toHaveAttribute("aria-current", "true");
+ await page.keyboard.press("r");
+ await expect(page.locator("[data-note-card]")).toHaveCount(0);
+ expect(JSON.parse(readFileSync(NOTES, "utf8")).notes[0].status).toBe("resolved");
+});
+
+test("a quote that is gone is still listed, flagged orphaned", async ({ page, request }) => {
+ const res = await request.post("/api/notes?article=priv/polemic-alpha", {
+ data: { op: { op: "add", text: "About a sentence the agent rewrote.", anchor: { kind: "text", section: "first", quote: "a sentence that is not in the article", prefix: "", suffix: "" } } },
+ });
+ expect(res.status()).toBe(200);
+ await page.goto(PAGE);
+ await expect(page.locator("[data-note-card][data-orphaned='true']")).toHaveCount(1);
+ await expect(page.locator("mark[data-note]")).toHaveCount(0);
+});
+
+test("a stale token is a 409 and the page re-reads; the last delete removes notes.json", async ({ page, request }) => {
+ await page.goto(PAGE);
+ await page.getByRole("button", { name: "+ whole article" }).click();
+ await page.getByLabel("note text").fill("Mine.");
+ await page.getByRole("button", { name: "save note" }).click();
+ await expect(page.locator("[data-note-card]")).toHaveCount(1);
+
+ // An agent writes in between (no token: the CLI's way).
+ const id = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].id;
+ const stale = await request.post("/api/notes?article=priv/polemic-alpha", { data: { token: "absent", op: { op: "status", id, status: "resolved" } } });
+ expect(stale.status()).toBe(409);
+ await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "reply", id, text: "seen" } } });
+
+ await page.locator("[data-note-card]").getByRole("button", { name: "resolve" }).click();
+ await expect(page.getByText(/reloaded; check and try again/)).toBeVisible();
+ await expect(page.locator("[data-note-card] [data-reply]")).toContainText("seen");
+
+ page.on("dialog", (d) => d.accept());
+ await page.locator("[data-note-card]").getByRole("button", { name: "delete" }).click();
+ await expect(page.locator("[data-note-card]")).toHaveCount(0);
+ expect(existsSync(NOTES)).toBe(false);
+});
+
+test("open notes are open decisions, linked to the note; ?note= opens on it", async ({ page, request }) => {
+ await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "For the inbox.", anchor: { kind: "section", section: "later" } } } });
+ const id = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].id;
+
+ await page.goto("/sites?notes=open");
+ await expect(page.locator("[data-article='priv/polemic-alpha'] [data-open-notes]")).toHaveAttribute("data-open-notes", "1");
+
+ await page.goto("/browse/decisions?kind=open-note");
+ const row = page.locator("[data-project='sites/priv/polemic-alpha'] [data-decision='open-note']");
+ await expect(row).toContainText("For the inbox.");
+ await row.getByRole("link").click();
+ await expect(page).toHaveURL(new RegExp(`/sites/priv/polemic-alpha\\?note=${id}`));
+ await expect(page.locator(`[data-note-card='${id}']`)).toHaveAttribute("aria-current", "true");
+});
+
+test("writes outside a report are refused", async ({ request }) => {
+ for (const q of ["article=priv/../pub", "article=priv/nope", "article=priv", "article=../x/y"]) {
+ const r = await request.post(`/api/notes?${q}`, { data: { op: { op: "add", text: "x", anchor: { kind: "whole" } } } });
+ expect([400, 403, 404]).toContain(r.status());
+ }
+ const bad = await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "x", anchor: { kind: "moment", file: "../../x.mp4", t: 1 } } } });
+ expect(bad.status()).toBe(400);
+ expect(existsSync(NOTES)).toBe(false);
+});
diff --git a/umtool/e2e/sites.spec.ts b/umtool/e2e/sites.spec.ts
@@ -0,0 +1,81 @@
+import { test, expect } from "@playwright/test";
+
+// ---------------------------------------------------------------------------
+// /sites: every site's articles, a site's page, its media and workspace files.
+//
+// priv/polemic-alpha published, a video + poster, linked to the project
+// sitews/polemic-alpha (slug in the draft's workspace)
+// priv/polemic-beta a draft
+// pub/gamma published on the public site
+// (e2e/fixtures/sites-fixture.mjs)
+// ---------------------------------------------------------------------------
+
+test("/sites lists every site, private first, with published and draft articles", async ({ page }) => {
+ const res = await page.goto("/sites");
+ expect(res?.status()).toBe(200);
+ await expect(page.getByRole("link", { name: "sites", exact: true }).first()).toHaveAttribute("aria-current", "page");
+
+ const sites = page.locator("[data-site]");
+ await expect(sites).toHaveCount(2);
+ await expect(sites.nth(0)).toHaveAttribute("data-site", "priv");
+ await expect(sites.nth(1)).toHaveAttribute("data-site", "pub");
+ await expect(page.locator("[data-site='priv'] [data-audience]")).toHaveAttribute("data-audience", "private");
+ await expect(page.locator("[data-site='priv'] [data-counts]")).toHaveAttribute("data-counts", "1/1");
+
+ const alpha = page.locator("[data-article='priv/polemic-alpha']");
+ await expect(alpha).toHaveAttribute("data-status", "published");
+ await expect(alpha).toContainText("Alpha: the rigged vote");
+ await expect(alpha.locator("[data-project-link='sitews/polemic-alpha']")).toBeVisible();
+ await expect(alpha).toContainText("alpha.json");
+ await expect(alpha.locator("img")).toHaveCount(1);
+ await expect(page.locator("[data-article='priv/polemic-beta']")).toHaveAttribute("data-status", "draft");
+});
+
+test("the filters are links, and compose", async ({ page }) => {
+ await page.goto("/sites");
+ await page.getByRole("link", { name: /^draft \d+$/ }).click();
+ await expect(page).toHaveURL(/status=draft/);
+ await expect(page.locator("[data-article]")).toHaveCount(1);
+ await expect(page.locator("[data-article='priv/polemic-beta']")).toBeVisible();
+
+ await page.goto("/sites?site=pub");
+ await expect(page.locator("[data-site]")).toHaveCount(1);
+ await expect(page.locator("[data-article='pub/gamma']")).toBeVisible();
+
+ await page.goto("/sites?notes=open");
+ await expect(page.locator("[data-article]")).toHaveCount(0);
+});
+
+test("a site's page plays its report videos, lists its project's takes and its workspace", async ({ page, request }) => {
+ await page.goto("/sites/priv");
+ await expect(page.locator("[data-report-video='polemic-alpha'] video")).toHaveAttribute("src", /file=video\.mp4/);
+ const project = page.locator("[data-video-project='sitews/polemic-alpha']");
+ await expect(project.locator("[data-takes]")).toHaveAttribute("data-takes", "2");
+ await expect(project).toContainText("like 1");
+
+ // media: ranged, read-only, and nothing outside the report dir
+ const v = await request.get("/api/sites/media?site=priv&report=polemic-alpha&file=video.mp4", { headers: { range: "bytes=0-99" } });
+ expect(v.status()).toBe(206);
+ expect(v.headers()["content-type"]).toBe("video/mp4");
+ expect((await request.get("/api/sites/media?site=priv&report=polemic-alpha&file=../polemic-beta/report.json")).status()).toBe(404);
+ expect((await request.get("/api/sites/media?site=priv&report=polemic-alpha&file=report.json")).status()).toBe(400);
+ expect((await request.get("/api/sites/media?corpus=/etc/passwd")).status()).toBe(400);
+
+ // workspace files: markdown renders, a draft folds, HTML is sandboxed
+ const files = page.locator("[data-section='files']");
+ await files.locator("[data-file='NOTES.md']").click();
+ await expect(page.locator("[data-opened='md']")).toContainText("Workspace notes");
+ await page.locator("[data-file='polemics/drafts/alpha.json']").click();
+ await expect(page.locator("[data-opened='json']")).toContainText("polemic-alpha");
+ await page.locator("[data-file='polemics/out/alpha.html']").click();
+ await expect(page.locator("iframe[sandbox='']")).toHaveCount(1);
+ const html = await request.get("/api/sites/workspace?ws=sitews&rel=polemics/out/alpha.html");
+ expect(html.headers()["content-security-policy"]).toContain("sandbox");
+ expect((await request.get("/api/sites/workspace?ws=sitews&rel=../sites/priv/site.json")).status()).toBe(404);
+ expect((await request.get("/api/sites/workspace?ws=..&rel=NOTES.md")).status()).toBe(404);
+});
+
+test("an unknown site or article is a 404", async ({ page }) => {
+ expect((await page.goto("/sites/nope"))?.status()).toBe(404);
+ expect((await page.goto("/sites/priv/nope"))?.status()).toBe(404);
+});