Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aumtool/app/sites/[site]/[report]/evidence/page.tsx | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/[site]/[report]/page.tsx | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleBody.tsx | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleReader.tsx | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleVideo.tsx | 13+++++++++++++
Aumtool/components/articles/EvidencePanel.tsx | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/EvidenceWalk.tsx | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/NoteCards.tsx | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/anchorDom.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-evidence.spec.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-notes.spec.ts | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/sites.spec.ts | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +});