recap / static /app.jsx
afif-ahmed's picture
deploy: sync from fe7cce1
ba54ea9 verified
raw
history blame
32.5 kB
// Recap — Bold Editorial UI
// Ported from the design bundle (direction-editorial.jsx) and wired to the
// real FastAPI backend at /api/patients and /api/answer. Single-instance
// app (no canvas), full-window, light + dark.
const { useState, useEffect, useMemo, useRef } = React;
const PALETTE = {
light: {
bg: '#f4ede2', paper: '#fbf7ef',
ink: '#1a1410', inkSoft: '#3a2e25',
muted: '#6b5c4a', faint: '#a8967f',
rule: '#d6c8b4', ruleSoft: '#e8ddc9',
accent: '#b8412e', accentSoft: '#f3dcd0',
mark: '#d4af37',
},
dark: {
bg: '#1a1410', paper: '#221a14',
ink: '#f4ede2', inkSoft: '#d6c8b4',
muted: '#a8967f', faint: '#6b5c4a',
rule: '#3a2e25', ruleSoft: '#2a2017',
accent: '#e8755e', accentSoft: '#2a1814',
mark: '#e0c060',
},
};
const CAT = {
diagnosis: { label: 'Diagnosis', hint: 'Clinical condition' },
visit: { label: 'Visit', hint: 'Patient encounter' },
lab: { label: 'Lab', hint: 'Laboratory result' },
report: { label: 'Report', hint: 'Clinical report or summary' },
scan: { label: 'Scan', hint: 'Medical imaging' },
procedure: { label: 'Procedure', hint: 'Operation or intervention' },
med: { label: 'Medication', hint: 'Prescribed drug' },
note: { label: 'Note', hint: 'Free-text clinical note' },
photo: { label: 'Photo', hint: 'Patient-supplied image' },
other: { label: 'Other', hint: 'Uncategorized event' },
};
// Inline lucide-style icons (24x24 viewBox). stroke inherits from parent.
// One glyph per event category, picked for instant recognition.
const ICONS = {
// alert-octagon — signals clinical importance for any diagnosis
diagnosis: (
<g>
<path d="M7.86 2h8.28L22 7.86v8.28L16.14 22H7.86L2 16.14V7.86z" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</g>
),
// stethoscope
visit: (
<g>
<path d="M11 2v2" />
<path d="M5 2v2" />
<path d="M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1" />
<path d="M8 15a6 6 0 0 0 12 0v-3" />
<circle cx="20" cy="10" r="2" />
</g>
),
// flask-conical
lab: (
<g>
<path d="M10 2v6.5L3.5 19a1 1 0 0 0 .9 1.5h15.2a1 1 0 0 0 .9-1.5L14 8.5V2" />
<path d="M9 2h6" />
<path d="M6.4 14.5h11.2" />
</g>
),
// file-text
report: (
<g>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v6h6" />
<path d="M9 13h6" />
<path d="M9 17h4" />
</g>
),
// image (frame + small sun + mountain) — universal "imaging" symbol
scan: (
<g>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</g>
),
// scissors
procedure: (
<g>
<circle cx="6" cy="6" r="3" />
<path d="M8.12 8.12 12 12" />
<path d="M20 4 8.12 15.88" />
<circle cx="6" cy="18" r="3" />
<path d="M14.8 14.8 20 20" />
</g>
),
// pill
med: (
<g>
<path d="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z" />
<path d="m8.5 8.5 7 7" />
</g>
),
// pen-line
note: (
<g>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</g>
),
// camera
photo: (
<g>
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</g>
),
// dot fallback
other: (
<circle cx="12" cy="12" r="3" />
),
};
function EventIcon({ category, size = 12 }) {
const paths = ICONS[category] || ICONS.other;
return (
<svg width={size} height={size} viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round"
style={{ display: 'block' }}>
{paths}
</svg>
);
}
const SERIF = '"Source Serif 4", "GT Sectra", "Tiempos Headline", Charter, Georgia, serif';
const SANS = '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
const MONO = '"JetBrains Mono", "SF Mono", ui-monospace, monospace';
function fmtDate(iso, opts = { y: true }) {
const d = new Date(iso + (iso.length === 10 ? 'T00:00:00Z' : ''));
if (opts.short) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return d.toLocaleDateString('en-US', {
year: opts.y ? 'numeric' : undefined,
month: 'short',
day: 'numeric',
});
}
// ─────────────────────────────────────────────────────────────────────
// Suggested questions per patient. Used as starter prompts only —
// actual answers come from /api/answer (real LLM via inference gateway).
const SUGGESTED = {
sarah: [
'When did her kidney function start declining?',
'What medications was she on when CKD was diagnosed?',
'Summarize her trajectory in 3 sentences.',
],
marcus: [
'How long from first symptom to diagnosis?',
'What was the response to R-CHOP?',
'Summarize this patient\'s journey.',
],
aisha: [
'What records does she have in foreign languages?',
'Is her current anemia recurrent or new?',
'What is her current pregnancy status?',
],
demo: [
'When did her kidney function start declining?',
'What was her first abnormal creatinine reading?',
'What medications was she on when CKD was diagnosed?',
],
};
// ─────────────────────────────────────────────────────────────────────
function App() {
const [patients, setPatients] = useState([]);
const [patientId, setPatientId] = useState(null);
const [dark, setDark] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/patients')
.then((r) => r.json())
.then((data) => {
setPatients(data);
if (data.length > 0) setPatientId(data[0].id);
setLoading(false);
})
.catch((e) => {
setError(String(e));
setLoading(false);
});
}, []);
const c = dark ? PALETTE.dark : PALETTE.light;
const patient = useMemo(
() => patients.find((p) => p.id === patientId),
[patients, patientId],
);
if (loading) {
return <Loading c={c} />;
}
if (error) {
return <ErrorView c={c} message={error} />;
}
if (!patient) {
return <ErrorView c={c} message="No patients available." />;
}
return (
<div style={{
position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
background: c.bg, color: c.ink, fontFamily: SANS, fontSize: 13,
}}>
<Masthead c={c} dark={dark} patient={patient} allPatients={patients}
onPatientChange={setPatientId}
onDarkToggle={() => setDark(!dark)} />
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<Document c={c} patient={patient} />
<ChatColumn c={c} patient={patient} />
</div>
</div>
);
}
function Loading({ c }) {
return (
<div style={{
position: 'absolute', inset: 0, background: c.bg, color: c.muted,
display: 'grid', placeItems: 'center', fontFamily: SERIF,
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 42, color: c.ink, letterSpacing: '-.03em' }}>
Recap<span style={{ color: c.accent }}>.</span>
</div>
<div style={{ fontStyle: 'italic', marginTop: 8 }}>loading the chart…</div>
</div>
</div>
);
}
function ErrorView({ c, message }) {
return (
<div style={{
position: 'absolute', inset: 0, background: c.bg, color: c.ink,
display: 'grid', placeItems: 'center', fontFamily: SERIF, padding: 24,
}}>
<div style={{ textAlign: 'center', maxWidth: 480 }}>
<div style={{ fontSize: 32, color: c.accent, letterSpacing: '-.02em' }}>
Something is off.
</div>
<div style={{ marginTop: 12, color: c.muted, fontStyle: 'italic' }}>{message}</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
function Masthead({ c, dark, patient, allPatients, onPatientChange, onDarkToggle }) {
const [open, setOpen] = useState(false);
return (
<div style={{
padding: '14px 28px', borderBottom: `1px solid ${c.rule}`,
background: c.bg, display: 'flex', alignItems: 'center', gap: 18,
}}>
<div style={{
fontFamily: SERIF, fontSize: 26, fontWeight: 500,
letterSpacing: '-0.025em', lineHeight: 1, color: c.ink,
}}>
Recap<span style={{ color: c.accent }}>.</span>
</div>
<div style={{
paddingLeft: 16, borderLeft: `1px solid ${c.rule}`,
fontFamily: SERIF, fontStyle: 'italic', fontSize: 13.5, color: c.muted,
lineHeight: 1.3, maxWidth: 280,
}}>
Reads the whole chart so you don't have to.
</div>
<div style={{ flex: 1 }} />
<div style={{ position: 'relative' }}>
<button onClick={() => setOpen(!open)} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '6px 12px', border: `1px solid ${c.rule}`, borderRadius: 2,
background: c.paper, color: c.ink, cursor: 'pointer',
fontFamily: SANS, fontSize: 12,
}}>
<span style={{ fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.06em' }}>
CASE №
</span>
<span style={{ fontFamily: SERIF, fontSize: 14, fontWeight: 500, color: c.ink }}>
{patient.display_name}
</span>
<span style={{ color: c.faint }}></span>
</button>
{open && (
<div style={{
position: 'absolute', top: '110%', right: 0, width: 320, marginTop: 4,
background: c.paper, border: `1px solid ${c.rule}`, borderRadius: 2,
padding: 4, zIndex: 10,
boxShadow: dark ? '0 8px 32px rgba(0,0,0,.5)' : '0 8px 32px rgba(0,0,0,.12)',
}}>
{allPatients.map((p, i) => (
<button key={p.id}
onClick={() => { onPatientChange(p.id); setOpen(false); }}
style={{
width: '100%', textAlign: 'left', padding: '10px 12px',
borderRadius: 2, border: 'none', cursor: 'pointer',
background: p.id === patient.id ? c.accentSoft : 'transparent',
color: c.ink, fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{
fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.06em',
}}>{String(i + 1).padStart(2, '0')}</span>
<span style={{ fontFamily: SERIF, fontSize: 16, fontWeight: 500 }}>
{p.display_name}
</span>
{p.age != null && (
<span style={{ fontSize: 11, color: c.muted }}>· {p.age}y</span>
)}
</div>
<div style={{
fontSize: 11.5, color: c.muted, marginTop: 4, lineHeight: 1.45,
fontStyle: 'italic', fontFamily: SERIF,
}}>
{p.summary}
</div>
</button>
))}
</div>
)}
</div>
<BackendBadge c={c} />
<button onClick={onDarkToggle} style={{
width: 28, height: 28, borderRadius: 2,
border: `1px solid ${c.rule}`, background: c.paper,
color: c.muted, cursor: 'pointer', fontSize: 14,
}}>{dark ? '☀' : '☾'}</button>
</div>
);
}
function BackendBadge({ c }) {
const [info, setInfo] = useState({ backend: '...' });
useEffect(() => {
fetch('/api/health').then((r) => r.json()).then(setInfo).catch(() => {});
}, []);
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 10px', border: `1px solid ${c.rule}`, borderRadius: 2,
background: c.paper,
fontFamily: MONO, fontSize: 10, color: c.muted, letterSpacing: '0.04em',
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: c.accent }} />
AMD MI300X · 192 GB · {info.backend}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
function Document({ c, patient }) {
const events = patient.events;
const groups = {};
events.forEach((e) => {
const y = e.date.slice(0, 4);
(groups[y] = groups[y] || []).push(e);
});
const years = Object.keys(groups).sort();
return (
<div style={{
flex: 1.4, minWidth: 0, overflowY: 'auto',
background: c.paper, borderRight: `1px solid ${c.rule}`,
}}>
<div style={{ padding: '32px 36px 24px', borderBottom: `1px solid ${c.rule}` }}>
<div style={{
fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.12em',
textTransform: 'uppercase', marginBottom: 12,
}}>
Patient Dossier · {events.length} events · {years.length} year{years.length === 1 ? '' : 's'} on record
</div>
<h1 style={{
fontFamily: SERIF, fontSize: 48, fontWeight: 500,
letterSpacing: '-0.03em', lineHeight: 1.05, color: c.ink,
margin: '0 0 12px',
}}>
{patient.display_name}<span style={{ color: c.accent }}>.</span>
</h1>
<div style={{
fontFamily: SERIF, fontStyle: 'italic', fontSize: 18, color: c.muted,
lineHeight: 1.45, maxWidth: 620, textWrap: 'pretty',
}}>
{patient.summary}
</div>
<div style={{ display: 'flex', gap: 24, marginTop: 22, alignItems: 'baseline' }}>
{patient.age != null && <Stat c={c} value={patient.age} label="years old" />}
{patient.gender && <Stat c={c} value={patient.gender} label="gender" />}
{patient.mrn && <Stat c={c} value={patient.mrn} label="MRN" mono />}
<Stat c={c} value={new Set(events.map((e) => e.source)).size} label="source docs" />
</div>
{patient.tags && patient.tags.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginTop: 20, flexWrap: 'wrap' }}>
{patient.tags.map((t) => (
<span key={t} style={{
fontFamily: SANS, fontSize: 11, color: c.inkSoft,
padding: '3px 10px', border: `1px solid ${c.rule}`, borderRadius: 2,
background: c.bg,
}}>{t}</span>
))}
</div>
)}
</div>
<div style={{ padding: '24px 36px 48px' }}>
{years.map((y, yi) => (
<YearSection key={y} c={c} year={y} events={groups[y]} first={yi === 0} />
))}
</div>
</div>
);
}
function Stat({ c, value, label, mono }) {
return (
<div>
<div style={{
fontFamily: mono ? MONO : SERIF,
fontSize: mono ? 14 : 22, fontWeight: 500, color: c.ink, lineHeight: 1,
}}>{value}</div>
<div style={{
fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em',
textTransform: 'uppercase', marginTop: 5,
}}>{label}</div>
</div>
);
}
function YearSection({ c, year, events, first }) {
const [activeId, setActiveId] = useState(null);
return (
<div style={{ position: 'relative', marginBottom: 32 }}>
<div style={{
display: 'flex', alignItems: 'baseline', gap: 14,
marginBottom: 8, paddingBottom: 8,
borderBottom: `1px solid ${c.ruleSoft}`, marginLeft: 80,
}}>
<h2 style={{
fontFamily: SERIF, fontSize: 32, fontWeight: 500, letterSpacing: '-0.02em',
color: c.ink, margin: 0, lineHeight: 1,
}}>{year}</h2>
<div style={{
fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.1em',
textTransform: 'uppercase',
}}>
{events.length} {events.length === 1 ? 'event' : 'events'}
</div>
</div>
<div style={{
position: 'absolute', left: 100, top: 56, bottom: 0,
width: 1, background: c.rule,
}} />
{events.map((e, ei) => (
<DocEvent key={e.id} c={c} e={e} index={ei}
active={activeId === e.id}
onClick={() => setActiveId(activeId === e.id ? null : e.id)} />
))}
</div>
);
}
function DocEvent({ c, e, index, active, onClick }) {
const cat = CAT[e.category] || CAT.other;
const [iconHover, setIconHover] = useState(false);
// Vertical center of the title line is roughly 26px from the top of the
// content button (≈12px category label + 3px gap + half of 22px title line).
// Center the icon there so it visually anchors to the title, not the date.
const iconCenterY = 26;
const iconSize = active ? 30 : 26;
const iconPadTop = Math.max(iconCenterY - iconSize / 2, 0);
return (
<div style={{
display: 'flex', alignItems: 'flex-start',
padding: '12px 0', position: 'relative',
}}>
<div style={{
width: 76, flexShrink: 0, paddingRight: 8,
paddingTop: 16, textAlign: 'right',
}}>
<div style={{
fontFamily: SERIF, fontSize: 14, color: c.ink, fontWeight: 500,
letterSpacing: '-0.01em',
}}>
{fmtDate(e.date, { y: false, short: true })}
</div>
<div style={{
fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.06em',
marginTop: 2,
}}>
{String(index + 1).padStart(2, '0')}
</div>
</div>
<div style={{
width: 32, flexShrink: 0, display: 'flex', justifyContent: 'center',
paddingTop: iconPadTop, position: 'relative', zIndex: 1,
}}>
{/* Hover wrapper sits exactly on the icon — tooltip uses bottom:100% relative to it */}
<div style={{ position: 'relative', display: 'inline-block' }}
onMouseEnter={() => setIconHover(true)}
onMouseLeave={() => setIconHover(false)}>
<div style={{
width: iconSize, height: iconSize,
borderRadius: e.category === 'diagnosis' ? 4 : '50%',
background: active ? c.accent : c.paper,
border: `1px solid ${active ? c.accent : c.rule}`,
display: 'grid', placeItems: 'center',
color: active ? c.paper : c.muted,
transition: 'all .15s',
boxShadow: iconHover && !active ? `0 0 0 4px ${c.accentSoft}` : 'none',
}}>
<EventIcon category={e.category} size={active ? 16 : 14} />
</div>
{iconHover && (
<div role="tooltip" style={{
position: 'absolute', bottom: '100%', left: '50%',
transform: 'translateX(-50%)', marginBottom: 8,
background: c.ink, color: c.bg,
padding: '6px 10px', borderRadius: 2,
fontFamily: MONO, fontSize: 10, letterSpacing: '0.06em',
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50,
boxShadow: '0 4px 14px rgba(0,0,0,.18)',
display: 'flex', alignItems: 'baseline', gap: 6,
}}>
<span style={{ fontWeight: 600, textTransform: 'uppercase' }}>
{cat.label}
</span>
<span style={{ opacity: 0.65 }}>· {cat.hint}</span>
{/* Tooltip tail */}
<span style={{
position: 'absolute', top: '100%', left: '50%',
transform: 'translateX(-50%)',
width: 0, height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: `5px solid ${c.ink}`,
}} />
</div>
)}
</div>
</div>
<button onClick={onClick} style={{
flex: 1, marginLeft: 18, textAlign: 'left',
background: active ? c.accentSoft : 'transparent',
padding: active ? '10px 14px' : '0',
marginTop: active ? -4 : 0, marginBottom: active ? -4 : 0,
border: 'none', cursor: 'pointer', color: c.ink, fontFamily: 'inherit',
borderRadius: 2,
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: MONO, fontSize: 9.5, color: c.faint,
textTransform: 'uppercase', letterSpacing: '0.1em',
}}>{e.category}</span>
{e.flag === 'critical' && (
<span style={{
fontFamily: MONO, fontSize: 9.5, color: c.accent,
textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600,
}}>· Critical</span>
)}
{(e.flag === 'high' || e.flag === 'low') && (
<span style={{
fontFamily: MONO, fontSize: 9.5, color: c.mark,
textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600,
}}>· {e.flag === 'high' ? 'High' : 'Low'}</span>
)}
</div>
<div style={{
fontFamily: SERIF, fontSize: 17, fontWeight: 500, color: c.ink,
letterSpacing: '-0.012em', lineHeight: 1.3, marginTop: 3, textWrap: 'balance',
}}>{e.title}</div>
{e.body && e.body !== e.title && (active || e.flag === 'critical') && (
<div style={{
fontFamily: SERIF, fontSize: 14, color: c.inkSoft, lineHeight: 1.5,
marginTop: 6, fontStyle: 'italic', maxWidth: 540, textWrap: 'pretty',
}}>{e.body}</div>
)}
{active && (
<div style={{
marginTop: 10, paddingTop: 8, borderTop: `1px solid ${c.rule}`,
fontFamily: MONO, fontSize: 10.5, color: c.muted,
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<span>Source: <span style={{ color: c.ink }}>{e.source}</span></span>
{e.page && <span>Page {e.page}</span>}
{e.snippet && (
<span style={{
fontStyle: 'italic', fontFamily: SERIF, fontSize: 12.5, color: c.inkSoft,
}}>"{e.snippet}"</span>
)}
</div>
)}
</button>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
function ChatColumn({ c, patient }) {
const [history, setHistory] = useState([]);
const [input, setInput] = useState('');
const [thinking, setThinking] = useState(false);
const scroller = useRef(null);
useEffect(() => {
setHistory([]);
setInput('');
}, [patient.id]);
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
}, [history, thinking]);
const send = async (text) => {
const q = (text || input).trim();
if (!q) return;
setInput('');
setHistory((h) => [...h, { role: 'user', text: q }]);
setThinking(true);
try {
const r = await fetch('/api/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patient_id: patient.id, question: q }),
});
const data = await r.json();
if (data.error) {
setHistory((h) => [...h, { role: 'assistant', text: `Error: ${data.error}`, citations: [] }]);
} else {
setHistory((h) => [...h, {
role: 'assistant',
text: data.text,
citations: data.citations || [],
}]);
}
} catch (err) {
setHistory((h) => [...h, {
role: 'assistant',
text: `Network error: ${String(err)}`,
citations: [],
}]);
} finally {
setThinking(false);
}
};
const examples = SUGGESTED[patient.id] || SUGGESTED.demo || [];
return (
<div style={{
flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: c.bg,
}}>
<div style={{ padding: '20px 24px 14px', borderBottom: `1px solid ${c.rule}` }}>
<div style={{
fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.12em',
textTransform: 'uppercase', marginBottom: 6,
}}>
The Reading Room
</div>
<div style={{
fontFamily: SERIF, fontSize: 22, fontWeight: 500,
letterSpacing: '-0.02em', color: c.ink, lineHeight: 1.15,
}}>
Ask a question.<br />
<span style={{ fontStyle: 'italic', color: c.muted }}>Get a cited answer.</span>
</div>
</div>
<div ref={scroller} style={{ flex: 1, overflowY: 'auto', padding: '18px 24px' }}>
{history.length === 0 && (
<div>
<div style={{
fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.1em',
textTransform: 'uppercase', marginBottom: 12,
}}>Suggested</div>
{examples.map((ex, i) => (
<button key={ex} onClick={() => send(ex)} style={{
display: 'flex', gap: 12, alignItems: 'flex-start',
width: '100%', textAlign: 'left',
padding: '14px 0',
borderTop: i === 0 ? `1px solid ${c.rule}` : 'none',
borderBottom: `1px solid ${c.rule}`,
background: 'transparent', border: 'none', cursor: 'pointer',
borderRadius: 0, color: c.ink, fontFamily: 'inherit',
}}>
<span style={{
fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.1em',
width: 22, paddingTop: 4, flexShrink: 0,
}}>0{i + 1}</span>
<span style={{
flex: 1, fontFamily: SERIF, fontSize: 16, lineHeight: 1.4,
color: c.ink, fontWeight: 500, letterSpacing: '-0.01em',
}}>{ex}</span>
<span style={{ color: c.accent, fontSize: 16, marginTop: 1 }}></span>
</button>
))}
</div>
)}
{history.map((m, i) => (
<div key={i} style={{ marginBottom: 22 }}>
{m.role === 'user' ? (
<div>
<div style={{
fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em',
textTransform: 'uppercase', marginBottom: 6,
}}>You asked</div>
<div style={{
fontFamily: SERIF, fontSize: 19, lineHeight: 1.35, color: c.ink,
fontWeight: 500, letterSpacing: '-0.015em',
}}>"{m.text}"</div>
</div>
) : (
<AssistantMessage c={c} m={m} patient={patient} />
)}
</div>
))}
{thinking && (
<div style={{
fontFamily: MONO, fontSize: 11, color: c.muted, letterSpacing: '0.04em',
padding: '8px 0',
}}>
<span style={{ animation: 'edit-blink 1s infinite' }}></span>
{' '}reading {patient.events.length} events…
</div>
)}
</div>
<div style={{ padding: '14px 24px 18px', borderTop: `1px solid ${c.rule}` }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
border: `1px solid ${c.rule}`, borderRadius: 2,
background: c.paper, padding: '10px 14px',
}}>
<span style={{
fontFamily: SERIF, color: c.accent, fontSize: 18, fontStyle: 'italic',
}}>?</span>
<input value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Ask anything about this chart…"
style={{
flex: 1, border: 'none', background: 'transparent',
color: c.ink, fontSize: 14, outline: 'none',
fontFamily: SERIF, padding: '2px 0',
}} />
<button onClick={() => send()} style={{
padding: '6px 14px', borderRadius: 2,
background: c.accent, border: 'none', color: c.paper,
cursor: 'pointer', fontSize: 12, fontWeight: 500,
fontFamily: SANS, letterSpacing: '0.02em',
}}>Ask →</button>
</div>
</div>
</div>
);
}
// Render markdown-ish bold + inline citation markers like [src:foo.pdf#p2].
function AssistantMessage({ c, m, patient }) {
// Replace [src:foo.pdf#p2] with superscript clickable cite numbers.
const citationsByKey = {};
let counter = 0;
const text = (m.text || '').replace(/\[src:([^\]#]+)(?:#p(\d+))?\]/g, (_match, src, page) => {
const key = `${src}|${page || ''}`;
if (!(key in citationsByKey)) {
counter += 1;
citationsByKey[key] = { n: counter, src, page: page ? parseInt(page, 10) : null };
}
return `‹CITE:${citationsByKey[key].n}›`;
});
// Now split on the placeholders + bold markdown.
const segments = text.split(/(‹CITE:\d+›|\*\*[^*]+\*\*)/g);
return (
<div>
<div style={{
fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em',
textTransform: 'uppercase', marginBottom: 8,
}}>The chart says</div>
<div style={{
fontFamily: SERIF, fontSize: 16.5, lineHeight: 1.55, color: c.ink,
letterSpacing: '-0.005em', textWrap: 'pretty',
}}>
{segments.map((seg, i) => {
if (seg.startsWith('‹CITE:')) {
const n = parseInt(seg.slice(6, -1), 10);
return (
<sup key={i} style={{
color: c.accent, fontFamily: SERIF, fontStyle: 'italic',
fontWeight: 700, fontSize: 11, padding: '0 2px',
}}>{n}</sup>
);
}
if (seg.startsWith('**') && seg.endsWith('**')) {
return <strong key={i} style={{ fontWeight: 600 }}>{seg.slice(2, -2)}</strong>;
}
return <React.Fragment key={i}>{seg}</React.Fragment>;
})}
</div>
{(m.citations && m.citations.length > 0) && (
<div style={{ marginTop: 16, paddingTop: 12, borderTop: `1px solid ${c.rule}` }}>
<div style={{
fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em',
textTransform: 'uppercase', marginBottom: 8,
}}>Drawn from</div>
{m.citations.map((cit, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'baseline', gap: 12, padding: '6px 0',
}}>
<span style={{
fontFamily: SERIF, fontStyle: 'italic', color: c.accent,
fontSize: 14, width: 18, flexShrink: 0,
}}>{i + 1}.</span>
<span style={{ flex: 1, fontSize: 12.5, lineHeight: 1.45 }}>
{cit.snippet && (
<span style={{ fontFamily: SERIF, color: c.ink, fontWeight: 500 }}>
{cit.snippet}
</span>
)}
{cit.snippet && <span style={{ color: c.muted }}> · </span>}
<span style={{ fontFamily: MONO, fontSize: 10.5, color: c.muted }}>
{cit.source_id}{cit.page ? ` p.${cit.page}` : ''}
</span>
</span>
</div>
))}
</div>
)}
</div>
);
}
// Blinking cursor keyframes
if (!document.getElementById('edit-keyframes')) {
const s = document.createElement('style');
s.id = 'edit-keyframes';
s.textContent = `@keyframes edit-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }`;
document.head.appendChild(s);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);