File size: 5,145 Bytes
3fc8e60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | "use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import { ScoreLadder } from "@/components/ScoreLadder";
import { highlightRanges } from "@/lib/citations";
import type { ChunkView } from "@/lib/api";
/**
* The Evidence sheet: the full clause behind a citation, with the matched span marked and
* the score trail that put it there.
*
* It slides in as a fresh sheet of paper laid over the page, with a hard ink edge — the
* conversation stays where it was, so the reader keeps their place. It is a reference
* being held up beside the text, not a navigation away from it.
*/
export function EvidencePanel({
chunk,
answerText,
onClose,
}: {
chunk: ChunkView | null;
answerText: string;
onClose: () => void;
}) {
const closeRef = useRef<HTMLButtonElement>(null);
const open = chunk !== null;
useEffect(() => {
if (!open) return;
closeRef.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<AnimatePresence>
{chunk ? (
<>
<motion.div
key="scrim"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14 }}
onClick={onClose}
className="fixed inset-0 z-40 bg-ink/25"
aria-hidden
/>
<motion.aside
key="sheet"
role="dialog"
aria-modal="true"
aria-label={`${chunk.law_label} Article ${chunk.article_no}`}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 400, damping: 40 }}
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-[38rem] flex-col border-l-2 border-ink bg-sheet"
>
<header className="flex items-start gap-4 border-b border-rule px-6 py-5">
<div className="min-w-0 flex-1">
<p className="marginal mb-1.5">{chunk.law_label} · {chunk.part_title}</p>
<h2 className="display text-[1.75rem] text-ink">
Article {chunk.article_no}
</h2>
{chunk.article_title ? (
<p className="mt-1 font-medium text-[0.95rem] text-ink-soft">
{chunk.article_title}
</p>
) : chunk.section ? (
<p className="mt-1 text-[0.95rem] text-ink-soft">{chunk.section}</p>
) : null}
</div>
<button
ref={closeRef}
type="button"
onClick={onClose}
aria-label="Close evidence"
className="marginal border border-rule px-2 py-1 transition-colors hover:border-ink hover:text-ink"
>
Esc
</button>
</header>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 border-b border-rule px-6 py-2.5">
<ScoreLadder chunk={chunk} />
<span className="instrument text-ink-faint">
p.{chunk.page_start}
{chunk.page_end !== chunk.page_start ? `–${chunk.page_end}` : ""}
</span>
{chunk.seq_total > 1 ? (
<span className="instrument text-ink-faint">
{chunk.seq + 1}/{chunk.seq_total}
</span>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<Highlighted text={stripHeader(chunk.text)} answer={answerText} />
</div>
<footer className="border-t border-rule px-6 py-3">
<p className="text-[0.78rem] leading-relaxed text-ink-faint">
Quoted verbatim from {chunk.law_title}. Marked spans are the sentences the
answer drew on.
</p>
</footer>
</motion.aside>
</>
) : null}
</AnimatePresence>
);
}
/** The chunk text carries a context header line; the sheet shows the clause itself. */
function stripHeader(text: string): string {
const newline = text.indexOf("\n");
return newline === -1 ? text : text.slice(newline + 1);
}
function Highlighted({ text, answer }: { text: string; answer: string }) {
const ranges = useMemo(() => highlightRanges(text, answer), [text, answer]);
if (ranges.length === 0) {
return <p className="statute text-[1.02rem] text-ink">{text}</p>;
}
const parts: React.ReactNode[] = [];
let cursor = 0;
ranges.forEach(([start, end], index) => {
if (start > cursor) parts.push(text.slice(cursor, start));
parts.push(<mark key={index}>{text.slice(start, end)}</mark>);
cursor = end;
});
if (cursor < text.length) parts.push(text.slice(cursor));
return <p className="statute text-[1.02rem] text-ink">{parts}</p>;
}
|