File size: 4,813 Bytes
baf8a7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { useEffect, useState } from "react";
import {
  WarningCircle,
  CircleNotch,
  FileText,
  Info,
} from "@phosphor-icons/react";
import type { Fact, Provenance } from "../lib/api";

export const ICON = { size: 16, weight: "regular" as const };

/** Error state. Inline, specific, never a bare "something went wrong". */
export function ErrorNote({ message, onRetry }: { message: string; onRetry?: () => void }) {
  return (
    <div className="err" role="alert">
      <WarningCircle size={17} weight="fill" style={{ flexShrink: 0, marginTop: 1 }} />
      <div>
        <div>{message}</div>
        {onRetry && (
          <button className="btn btn-ghost btn-sm" style={{ marginTop: 8 }} onClick={onRetry}>
            Try again
          </button>
        )}
      </div>
    </div>
  );
}

/** Loading state shaped like the table it replaces, not a spinner in a void. */
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
  return (
    <div aria-busy="true" aria-label="Loading">
      {Array.from({ length: rows }).map((_, i) => (
        <div className="skel-row" key={i}>
          <div className="skel" style={{ width: `${58 + ((i * 13) % 34)}%` }} />
          <div className="skel" style={{ width: "68%" }} />
          <div className="skel" style={{ width: `${44 + ((i * 21) % 42)}%` }} />
          <div className="skel" style={{ width: "56%" }} />
        </div>
      ))}
    </div>
  );
}

export function Empty({
  icon, title, children,
}: { icon?: React.ReactNode; title: string; children?: React.ReactNode }) {
  return (
    <div className="empty">
      <div className="ico">{icon ?? <FileText size={26} />}</div>
      <h3>{title}</h3>
      {children && <p>{children}</p>}
    </div>
  );
}

export function Spinner() {
  return <CircleNotch size={15} className="spin" aria-hidden />;
}

export function Note({
  tone = "accent", children,
}: { tone?: "accent" | "warn" | "quiet"; children: React.ReactNode }) {
  const cls = tone === "warn" ? "note note-warn" : tone === "quiet" ? "note note-quiet" : "note";
  return <div className={cls}>{children}</div>;
}

/** Citation popover. Follows the pointer, driven by native hover events. */
export function CitationPopover({ container }: { container: React.RefObject<HTMLElement | null> }) {
  const [pop, setPop] = useState<{
    x: number; y: number; source: string; locator: string; raw: string; status: string;
  } | null>(null);

  useEffect(() => {
    // Listen on the document, not on container.current. The container renders
    // only once its content has loaded, so an effect that reads the ref on mount
    // finds null and silently never attaches: the popover would never appear.
    const onOver = (e: Event) => {
      const t = (e.target as HTMLElement)?.closest?.("cite");
      if (!t) return;
      if (container.current && !container.current.contains(t)) return;
      const r = t.getBoundingClientRect();
      setPop({
        x: Math.min(r.left, window.innerWidth - 440),
        y: r.bottom + 8,
        source: t.getAttribute("data-source") ?? "",
        locator: t.getAttribute("data-locator") ?? "",
        raw: t.getAttribute("data-raw") ?? "",
        status: t.getAttribute("data-status") ?? "",
      });
    };
    const onOut = (e: Event) => {
      if ((e.target as HTMLElement)?.closest?.("cite")) setPop(null);
    };

    document.addEventListener("mouseover", onOver);
    document.addEventListener("mouseout", onOut);
    return () => {
      document.removeEventListener("mouseover", onOver);
      document.removeEventListener("mouseout", onOut);
    };
  }, [container]);

  if (!pop) return null;
  return (
    <div className="pop" style={{ left: pop.x, top: pop.y }} role="tooltip">
      <div className="pop-t">
        {pop.status === "proposed" ? "proposed issue term" : `sourced · ${pop.status}`}
      </div>
      <div style={{ fontWeight: 500 }}>{pop.source}</div>
      <div style={{ color: "#93a5b8", marginTop: 2 }}>{pop.locator}</div>
      {pop.raw && <div className="pop-raw">{pop.raw}</div>}
    </div>
  );
}

/** Provenance rendered as a list, used in the ledger drawer. */
export function ProvenanceList({ provenance }: { provenance: Provenance[] }) {
  return (
    <div style={{ display: "grid", gap: 6 }}>
      {provenance.map((p, i) => (
        <div key={i} style={{ fontSize: 11.5, lineHeight: 1.5 }}>
          <div style={{ fontWeight: 500 }}>{p.source_title}</div>
          <div className="f-src">{p.locator}</div>
          {p.raw_line && <div className="ev-raw">{p.raw_line}</div>}
        </div>
      ))}
    </div>
  );
}

export function StatusChip({ status }: { status: string }) {
  return <span className={`chip chip-${status}`}>{status}</span>;
}

export function factTone(f: Fact): string {
  return f.status;
}

export { Info };