Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board

This commit is contained in:
2026-08-22 17:38:51 +02:00
41 changed files with 2140 additions and 107 deletions
+190 -23
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
const BOARD_W = 2400
const BOARD_H = 1500
@@ -19,7 +20,7 @@ function screenshotFile(file: File, index: number) {
}
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) {
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
}
@@ -61,6 +62,8 @@ export function App() {
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [reportOpen, setReportOpen] = useState(false)
const [playerName, setPlayerName] = useState('Player')
const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false)
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
@@ -69,6 +72,9 @@ export function App() {
const [flagsOpen, setFlagsOpen] = useState(false)
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -94,14 +100,24 @@ export function App() {
useEffect(() => {
if (adminRoute) return
const params = new URLSearchParams(window.location.search)
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => {
setIsAdmin(Boolean(session?.isAdmin)); setPlayerName(String(session?.playerName || 'Player'))
}).catch(() => setIsAdmin(false))
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
const openLevel = async (slug: string) => {
const data = await loadLevelBySlug(slug, editQuery)
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
if ((data.goals.some(goal => goal.status === 'pending') || data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId))
&& !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
try {
const playthroughResponse = await fetch('/api/playthroughs/current')
if (playthroughResponse.ok && playthroughResponse.status !== 204) {
const playthrough = await playthroughResponse.json() as PlaythroughState
setActivePlaythroughId(playthrough.node?.kind === 'level' && playthrough.node.levelSlug === data.id ? playthrough.playthrough.id : null)
} else setActivePlaythroughId(null)
} catch { setActivePlaythroughId(null) }
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
}
const boot = async () => {
@@ -259,7 +275,7 @@ export function App() {
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
return
}
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
setLinkFrom(null)
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
}
@@ -360,6 +376,7 @@ export function App() {
form.append('x', String(position.x))
form.append('y', String(position.y))
try {
setStatus(source === 'clipboard' ? 'PASTING SCREENSHOT · READING SOURCE…' : `IMPORTING ${file.name.toUpperCase()} · READING SOURCE…`)
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const uploaded: UploadedCaseDocument = await response.json()
@@ -372,14 +389,36 @@ export function App() {
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
})
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
if (deterministicCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(deterministicCompletion)
if (analysis.awardedFlags.length) {
window.clearTimeout(saveTimer.current)
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
await loadLevelBySlug(caseState.id, editQuery)
setSelected(document.id)
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
setStatus(deterministicCompletion ? caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
const judgeResponse = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents/${encodeURIComponent(document.id)}/judge`, { method: 'POST' })
if (judgeResponse.ok) {
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
if (semanticCompletion || semantic.awardedFlags.length) {
if (semanticCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(semanticCompletion)
window.clearTimeout(saveTimer.current)
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
await loadLevelBySlug(caseState.id, editQuery)
setSelected(document.id)
}
if (semanticCompletion) setStatus(caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE')
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
else setStatus('SCREENSHOT SAVED · SOURCE DOES NOT YET PROVE THE OBJECTIVE')
} else if (source === 'clipboard') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else setStatus(`IMPORTED · ${file.name.toUpperCase()} · TEXT ANALYZED`)
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
} catch (error) {
@@ -388,6 +427,56 @@ export function App() {
}
}, [caseState, loadLevelBySlug, requestedEditMode, update])
const continueAfterGoal = async () => {
if (!activePlaythroughId) { setCompletedGoal(null); return }
setAdvancing(true)
try {
const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
})
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
else window.location.assign('/')
} finally { setAdvancing(false) }
}
const openCaseReport = async () => {
if (!caseState?.report) return
window.clearTimeout(saveTimer.current)
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
setStatus('COMPILING CASE REPORT…')
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}${editQuery}`, {
method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(caseState),
})
if (saved.ok) await loadLevelBySlug(caseState.id,editQuery)
setReportOpen(true)
setStatus(saved.ok ? 'CASE REPORT COMPILED FROM BOARD' : 'CASE REPORT OPEN · LATEST SERVER COPY')
}
const submitReport = async (input:CaseReportSubmissionInput) => {
if (!caseState?.report) throw new Error('Case report unavailable')
setStatus('SUBMITTING CASE REPORT…')
const response=await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/report/submissions`,{
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),
})
if (!response.ok) { const body=await response.json().catch(() => ({})); throw new Error(body.error || 'Report submission failed') }
const report=await response.json() as CaseReport
const reportEvidence=report.claims.flatMap(claim => claim.evidence)
const byDocument=new Map(reportEvidence.map(item => [item.documentExhibitId,item]))
const byConnection=new Map(reportEvidence.map(item => [item.connectionId,item]))
setCaseState(current => current ? {
...current,report,
exhibits:current.exhibits.map(exhibit => exhibit.type === 'document' && byDocument.has(exhibit.id)
? { ...exhibit,publishedAt:byDocument.get(exhibit.id)!.publishedAt,sourceCitation:byDocument.get(exhibit.id)!.sourceCitation,sourceUri:byDocument.get(exhibit.id)!.sourceUri,displayNumber:byDocument.get(exhibit.id)!.displayNumber }
: exhibit),
connections:current.connections.map(connection => byConnection.has(connection.id)
? { ...connection,label:byConnection.get(connection.id)!.relationText || undefined } : connection),
} : current)
setStatus(report.status === 'accepted' ? 'CASE REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED FOR REVISION' : 'REPORT NEEDS SUPPORTING EVIDENCE')
return report
}
useEffect(() => {
if (!caseState) return
const handlePaste = (event: ClipboardEvent) => {
@@ -413,6 +502,10 @@ export function App() {
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
@@ -431,7 +524,8 @@ export function App() {
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
<button onClick={() => setHelpOpen(true)}>HELP</button>
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
@@ -459,7 +553,7 @@ export function App() {
<div className="doc-list">
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
</button>)}
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
</div>
@@ -467,10 +561,11 @@ export function App() {
</aside>
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => reportAttentionCount ? void openCaseReport() : setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? reportAttentionCount ? 'SOURCE VERIFIED · REPORT REQUIRED' : 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
goals={caseState.goals}
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
canEdit={canAuthor}
@@ -606,9 +701,14 @@ export function App() {
onSave={saveThread}
onRemove={() => removeThread(threadDraft.id)}
/>}
{reportOpen && caseState.report && <CaseReportPanel key={`${caseState.id}:${caseState.report.status}:${caseState.report.claims.flatMap(claim => claim.evidence).length}`}
report={caseState.report} defaultInvestigator={caseState.report.investigatorName || playerName} hasNext={Boolean(activePlaythroughId)}
onClose={() => setReportOpen(false)} onSubmit={submitReport}
onContinue={() => activePlaythroughId ? void continueAfterGoal() : setReportOpen(false)}/>}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
</main>
}
@@ -758,7 +858,10 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
setDraggingThreadTagId(null)
setDraggingWidget(false)
setTrashActive(false)
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) {
const exhibit=byId.get(completedDrag.id)
if (exhibit && exhibitWidget(exhibit.type).capabilities.discardable) onDiscardExhibit(completedDrag.id)
}
trashTarget.current = false
}
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
@@ -825,7 +928,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<header><span>{definition.label.toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
@@ -945,6 +1048,50 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
</form></div>
}
function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubmit, onContinue }: {
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
}) {
const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator)
const [evidence,setEvidence]=useState<CaseReportSubmissionInput['evidence']>(() => report.claims.flatMap(claim => claim.evidence.map(item => ({
connectionId:item.connectionId,documentExhibitId:item.documentExhibitId,relationText:item.relationText || 'Proof that…',
publishedAt:item.publishedAt?.slice(0,10),sourceCitation:item.sourceCitation,sourceUri:item.sourceUri,
}))))
const [busy,setBusy]=useState(false)
const [error,setError]=useState('')
const updateEvidence=(connectionId:string,patch:Partial<CaseReportSubmissionInput['evidence'][number]>) => setEvidence(items => items.map(item => item.connectionId === connectionId ? { ...item,...patch } : item))
const submit=async (event:React.FormEvent) => {
event.preventDefault();setBusy(true);setError('')
try { await onSubmit({ investigatorName,evidence }) }
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
finally { setBusy(false) }
}
const draftByConnection=new Map(evidence.map(item => [item.connectionId,item]))
return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI7 / EVIDENTIARY FINDING</span></div>
{report.claims.map((claim,claimIndex) => <section className="report-claim" key={claim.claimExhibitId}>
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
<h4>EVIDENCE</h4>
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : ''}`} key={item.connectionId}>
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted && <em>CONTENT VERIFIED</em>}</div>
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
<label><span>SOURCE LINK · IF AVAILABLE</span><input aria-label={`Source link for Exhibit ${item.displayNumber}`} type="url" value={draft.sourceUri || ''} placeholder="https://…" onChange={event => updateEvidence(item.connectionId,{ sourceUri:event.target.value })}/></label>
</article> })}
</section>)}
<label className="report-investigator"><span>INVESTIGATOR</span><input aria-label="Investigator name" value={investigatorName} maxLength={300} onChange={event => setInvestigatorName(event.target.value)}/></label>
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
{error && <p className="flag-error">{error}</p>}
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
? <button className="primary" type="button" onClick={onContinue}>{hasNext ? 'CONTINUE' : 'CLOSE CASE'} <span></span></button>
: <button className="primary" type="submit" disabled={busy}>{busy ? 'FILING…' : 'SUBMIT CASE REPORT'}</button>}</div>
</div>
</form></div>
}
function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) {
const [label, setLabel] = useState(connection.label || '')
const [tightness, setTightness] = useState(connection.tightness ?? 65)
@@ -958,8 +1105,8 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
<p>What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
<label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
@@ -968,19 +1115,35 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
</form></div>
}
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
const [minimized, setMinimized] = useState(false)
const partyById = new Map(parties.map(party => [party.id, party]))
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
const pending = goals.filter(goal => goal.status === 'pending').length
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button>
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
</aside>
}
function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; hasNext: boolean; busy: boolean; onContinue: () => void }) {
return <div className="goal-complete-shade" role="dialog" aria-modal="true" aria-labelledby="goal-complete-title">
<section className="goal-complete-card">
<div className="goal-complete-signal"><i/><span>GU-NET SOURCE AUTHENTICATION</span><i/></div>
<small>OBJECTIVE COMPLETE</small>
<h2 id="goal-complete-title">{goal.title}</h2>
<p>{goal.completionMessage || 'The submitted evidence satisfies this objective.'}</p>
<div className="goal-complete-stamp">SOURCE<br/><b>VERIFIED</b></div>
<button type="button" disabled={busy} onClick={onContinue}>{busy ? 'OPENING NEXT FILE…' : hasNext ? 'CONTINUE' : 'RETURN TO BOARD'} <span></span></button>
</section>
</div>
}
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
const [body, setBody] = useState(brief.body)
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
@@ -1149,12 +1312,14 @@ function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; on
type MatchRuleDraft = {
id?: string
name: string
sourceLabel: string
sourceUri: string
flagKey: string
minimumAnchorMatches: number
enabled: boolean
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
}
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', sourceLabel:'', sourceUri:'', flagKey: '', minimumAnchorMatches: 1, enabled: true,
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
@@ -1168,7 +1333,7 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
setRules(await response.json())
}, [levelId])
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,sourceLabel:rule.sourceLabel || '',sourceUri:rule.sourceUri || '',flagKey:rule.flagKey,
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
const submit = async (event: React.FormEvent) => {
@@ -1176,7 +1341,7 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
try {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
body: JSON.stringify({ name:draft.name,sourceLabel:draft.sourceLabel,sourceUri:draft.sourceUri,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
})
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
@@ -1206,6 +1371,8 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
<form className="match-rule-form" onSubmit={event => void submit(event)}>
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
<div className="match-rule-fields"><label className="field"><span>SOURCE LABEL · OPTIONAL</span><input value={draft.sourceLabel} maxLength={300} onChange={event => setDraft(value => ({ ...value,sourceLabel:event.target.value }))} placeholder="Google Patents · GB695913A"/></label>
<label className="field"><span>CANONICAL URL · OPTIONAL</span><input type="url" value={draft.sourceUri} maxLength={2000} onChange={event => setDraft(value => ({ ...value,sourceUri:event.target.value }))} placeholder="https://patents.google.com/…"/></label></div>
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
+1
View File
@@ -66,6 +66,7 @@ const folder: FolderExhibit = {
const state: CaseState = {
brief: { body: '', concepts: [] },
goals: [],
id: 'test-level',
title: 'Test',
subtitle: '',
+6 -1
View File
@@ -205,6 +205,8 @@ type LegacyCaseState = {
subtitle: string
viewport: Viewport
brief?: CaseState['brief']
goals?: CaseState['goals']
report?: CaseState['report']
updatedAt?: string
levelStatus?: string
sourceTemplateVersionId?: string
@@ -238,6 +240,8 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport,
relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] },
goals: state.goals || [],
report: state.report,
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
@@ -255,6 +259,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index),
publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined,
sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [],
sourceCitation: String(document.sourceCitation || '') || undefined,
regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined,
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
@@ -278,7 +283,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0),
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], report: state.report, revision: Number(state.revision || 0),
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetReg
describe('frontend exhibit registry', () => {
it('registers every normalized exhibit type', () => {
const types = ['folder', 'document', 'note', 'event', 'party'] as const
const types = ['folder', 'document', 'note', 'event', 'party', 'claim'] as const
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort())
expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED')
})
+11 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react'
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
export type WidgetCommand =
@@ -72,6 +72,13 @@ function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
</div>
}
function ClaimWidget({ exhibit }: ExhibitWidgetProps) {
if (exhibit.type !== 'claim') return null
return <div className="card-content claim-content"><div className="claim-heading"><BadgeCheck size={18}/><span>PROPOSITION TO PROVE</span></div>
<blockquote>{exhibit.statement}</blockquote><small>CONNECT SOURCE EVIDENCE WITH RED THREAD</small>
</div>
}
function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
if (exhibit.type !== 'document') return null
return <strong>{exhibit.title}</strong>
@@ -82,6 +89,7 @@ const notePorts = (exhibit: Exhibit) => [{ id:'knot',x:exhibit.x + exhibit.width
const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false }
const searchable = (exhibit: Exhibit) => exhibit.type === 'document'
? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase()
: exhibit.type === 'claim' ? [exhibit.title,exhibit.statement].join('\n').toLocaleLowerCase()
: [exhibit.title,exhibit.content].join('\n').toLocaleLowerCase()
export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition> = {
@@ -101,6 +109,8 @@ export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition>
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
party: { modelKind:'exhibit',visualType:'party',shell:'card',defaultSize:{width:280,height:190},capabilities:standardCapabilities,
heading:exhibit => exhibit.type === 'party' && exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:PartyWidget },
claim: { modelKind:'exhibit',visualType:'claim',shell:'card',defaultSize:{width:310,height:180},capabilities:{...standardCapabilities,discardable:false},
heading:() => 'CASE CLAIM / UNPROVEN',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:ClaimWidget },
}
export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
+26 -2
View File
@@ -51,6 +51,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.case-heading { position: absolute; top: 20px; left: 27px; z-index: 2; display: flex; color: #dde2de; pointer-events: none; }
.case-heading h1 { font: 500 24px Special Elite, serif; margin: 6px 0 5px; letter-spacing: .02em; }
.case-heading p { margin: 0; color: #a06d3e; font: 9px IBM Plex Mono; letter-spacing: .13em; }
.active-goal { pointer-events: auto; width: min(430px, 48vw); margin-top: 13px; padding: 8px 11px; display: grid; gap: 3px; text-align: left; border: 1px solid #536860; border-left: 3px solid #cf8644; background: #0b211ccc; color: #d5dcd8; box-shadow: 3px 4px #02090766; cursor: pointer; }
.active-goal span { color: #d18b49; font: 600 7px IBM Plex Mono; letter-spacing: .13em; }.active-goal b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 9px IBM Plex Mono; }.active-goal.complete { border-left-color: #71b889; }.active-goal.complete span { color: #7fc594; }
.case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; }
.case-number b { color: #bdc9c3; font-size: 13px; }
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
@@ -139,6 +141,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
.evidence-card.claim { min-height: 180px; padding: 15px 18px; background: linear-gradient(105deg,#e2dfcf,#d3d1c2); border: 2px solid #b99a68; box-shadow: 8px 10px 0 #020b0980,0 0 0 2px #526059; }
.evidence-card.claim::before { content:''; position:absolute; z-index:2; top:-7px; left:50%; width:13px; height:13px; translate:-50% 0; border-radius:50%; background:#9e392f; border:2px solid #5d1e1b; box-shadow:0 2px 2px #0006; }
.evidence-card.claim::after { background:#827d6e; }
.claim-content { padding-top:12px; }.claim-heading { display:flex; align-items:center; gap:7px; color:#89502b; font:600 7px IBM Plex Mono; letter-spacing:.13em; }
.claim-content blockquote { margin:14px 0 16px; padding:0; color:#202a26; font:22px/1.25 Special Elite; }.claim-content > small { color:#66716b; font:7px IBM Plex Mono; letter-spacing:.08em; }
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
@@ -193,12 +200,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.brief-panel > header { position: sticky; z-index: 3; top: 0; height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-panel > p { margin: 16px; padding: 13px; white-space: pre-line; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-goals { margin: 0 16px 16px; display: grid; gap: 7px; }.brief-goals section { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px; border: 1px solid #89928b; background: #d5d5ca; }.brief-goals section > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid #9b6232; border-radius: 50%; color: #854d25; font: 600 10px IBM Plex Mono; font-style: normal; }.brief-goals section > div { display: grid; gap: 4px; }.brief-goals b { color: #293b35; font: 600 9px IBM Plex Mono; }.brief-goals span { color: #5b6862; font: 10px/1.4 Special Elite; }.brief-goals em { color: #9a5f2e; font: 600 7px IBM Plex Mono; font-style: normal; letter-spacing: .08em; }.brief-goals section.complete { background: #cbd9cc; border-color: #78927d; }.brief-goals section.complete > i { border-color: #437558; background: #4d7f60; color: #f1f1e7; }.brief-goals section.complete em { color: #34644a; }
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section.just-resolved { animation: concept-resolved 1.15s ease-out; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
.classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief, .new-party-from-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-actions > span { margin-right: auto; display: inline-flex; align-items: center; gap: 5px; color: #31584d; font: 600 8px IBM Plex Mono; }.edit-brief, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }
@keyframes concept-resolved { 0% { background: #e8b76c; box-shadow: inset 4px 0 #b56127; } 100% { background: #d5ddcf; box-shadow: inset 0 0 transparent; } }
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
.goal-complete-shade { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 18px; background: #020907d9; backdrop-filter: blur(3px); animation: goal-shade-in .28s ease-out both; }.goal-complete-card { position: relative; width: min(570px, calc(100vw - 36px)); min-height: 390px; padding: 48px 54px 43px; overflow: hidden; display: flex; flex-direction: column; align-items: center; text-align: center; border: 1px solid #8b9d95; background: radial-gradient(circle at 50% 20%, #173c33, #0a211c 62%); box-shadow: 0 0 0 5px #081511, 0 0 0 6px #4f635b, 16px 19px 0 #0008; animation: goal-card-in .52s cubic-bezier(.16,.8,.2,1) both; }.goal-complete-card::before { content: ''; position: absolute; inset: 11px; pointer-events: none; border: 1px solid #405b51; }.goal-complete-signal { width: 100%; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; color: #78928a; font: 7px IBM Plex Mono; letter-spacing: .17em; }.goal-complete-signal i { height: 1px; background: #526d64; }.goal-complete-card > small { margin-top: 40px; color: #e09a52; font: 600 9px IBM Plex Mono; letter-spacing: .25em; }.goal-complete-card h2 { max-width: 430px; margin: 12px 0 13px; color: #edf1ed; font: 27px/1.18 Special Elite; }.goal-complete-card p { margin: 0; color: #9fb1aa; font: 9px/1.6 IBM Plex Mono; letter-spacing: .08em; }.goal-complete-stamp { margin: 24px 0; padding: 8px 14px 6px; transform: rotate(-3deg); border: 2px solid #8a5e39; color: #bd7c43; font: 7px IBM Plex Mono; letter-spacing: .15em; opacity: .9; }.goal-complete-stamp b { font-size: 15px; }.goal-complete-card > button { min-width: 190px; padding: 11px 17px; border: 1px solid #d29351; background: #a25527; color: #fff0db; font: 600 10px IBM Plex Mono; letter-spacing: .14em; cursor: pointer; box-shadow: 4px 5px #020907; }.goal-complete-card > button:hover:not(:disabled) { background: #c06a31; }.goal-complete-card > button:disabled { opacity: .58; cursor: wait; }.goal-complete-card > button span { margin-left: 8px; }
@keyframes goal-shade-in { from { opacity: 0; } }
@keyframes goal-card-in { from { opacity: 0; transform: translateY(24px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
.temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
.document-locator-beam { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; }
@@ -285,6 +296,17 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
.case-report-shade { padding:24px; }.case-report { width:min(900px,94vw); height:min(860px,94vh); display:grid; grid-template-rows:31px minmax(0,1fr); overflow:hidden; }
.case-report > header { cursor:default; }.case-report-paper { overflow:auto; padding:42px clamp(26px,7vw,74px) 34px; color:#202622; background-color:#ebe7d8; background-image:linear-gradient(#7e897e18 1px,transparent 1px); background-size:100% 28px; box-shadow:inset 0 0 46px #8d877466; font-family:Special Elite,serif; }
.case-report-letterhead { position:relative; padding-bottom:22px; border-bottom:3px double #343b37; text-align:center; }.case-report-letterhead small,.case-report-letterhead span { display:block; color:#59625d; font:7px IBM Plex Mono; letter-spacing:.16em; }.case-report-letterhead h2 { margin:9px 0 7px; color:#1d2622; font:30px Special Elite; letter-spacing:.11em; }
.report-claim { margin-top:30px; }.report-claim h3 { margin:0 0 22px; font:21px/1.45 Special Elite; }.report-claim h3 span { display:block; margin-bottom:5px; color:#915c30; font:7px IBM Plex Mono; letter-spacing:.14em; }.report-claim h4 { margin:0; padding-bottom:6px; border-bottom:1px solid #4c554f; font:600 8px IBM Plex Mono; letter-spacing:.15em; }
.report-empty-evidence { margin-top:13px; padding:17px; border:1px dashed #8a6550; color:#76513c; background:#d9d1bd; font:10px/1.5 IBM Plex Mono; }
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
.report-evidence label,.report-investigator { display:grid; gap:4px; margin-top:9px; }.report-evidence label > span,.report-investigator > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-evidence input,.report-evidence textarea,.report-investigator input { width:100%; min-width:0; padding:7px 8px; border:0; border-bottom:1px solid #747b75; outline:0; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; resize:vertical; }.report-evidence input:focus,.report-evidence textarea:focus,.report-investigator input:focus { border-bottom-color:#9b582d; background:#f6f0dc; box-shadow:inset 3px 0 #b2784a; }
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator input { max-width:390px; }
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
.case-report-actions { position:sticky; bottom:-34px; display:flex; justify-content:flex-end; gap:9px; margin:30px -12px -20px; padding:14px 12px 20px; background:linear-gradient(transparent,#ebe7d8 25%); }.case-report-actions button { border:1px outset #8c948e; padding:10px 13px; color:#34443e; background:#c8cbc4; cursor:pointer; font:8px IBM Plex Mono; letter-spacing:.06em; }.case-report-actions .primary { float:none; color:#fff; background:#174337; }.case-report-actions button:disabled { opacity:.55;cursor:progress; }
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
@media (max-width: 900px) {
@@ -306,12 +328,14 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.timeline-track { margin: 0 23px; }
.document-window { width: 80vw; }
.case-heading { left: 18px; }
.active-goal { width: min(360px, calc(100vw - 92px)); }
.case-number { display: none; }
.brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; }
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
.case-report-shade { padding:0; }.case-report { width:100vw;height:100dvh;border:0; }.case-report-paper { padding:28px 17px 24px; }.report-fields { grid-template-columns:1fr;gap:0; }.report-evidence-heading { grid-template-columns:auto 1fr; }.report-evidence-heading em { grid-column:1/-1;width:max-content; }.case-report-actions { bottom:-24px;margin-left:-7px;margin-right:-7px; }
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
.board-actions > b { display: none; }
.board-actions > span { margin: 0 2px; }
@@ -321,7 +345,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
}
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse, .goal-complete-shade, .goal-complete-card { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
/* Narrative layer: splash + NPC dialogue */
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
+94 -2
View File
@@ -1,4 +1,4 @@
export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party'
export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party' | 'claim'
export type PartyKind = 'person' | 'organization'
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
@@ -34,11 +34,14 @@ export interface FolderExhibit extends ExhibitBase {
export interface DocumentExhibit extends ExhibitBase {
type: 'document'
/** Stable, board-local number used when citing this exhibit. */
displayNumber?: number
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
requiredFlags?: string[]
publishedAt?: string
capturedAt?: string
sourceUri?: string
sourceCitation?: string
body: string[]
regions: DocumentRegion[]
assetId?: string
@@ -53,6 +56,7 @@ export interface DocumentUploadAnalysis {
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
matchedFlags: string[]
awardedFlags: string[]
goals: LevelGoal[]
}
export interface UploadedCaseDocument extends DocumentExhibit {
@@ -79,7 +83,12 @@ export interface PartyExhibit extends ExhibitBase {
aliases: string[]
}
export type Exhibit = FolderExhibit | DocumentExhibit | NoteExhibit | EventExhibit | PartyExhibit
export interface ClaimExhibit extends ExhibitBase {
type: 'claim'
statement: string
}
export type Exhibit = FolderExhibit | DocumentExhibit | NoteExhibit | EventExhibit | PartyExhibit | ClaimExhibit
export type Evidence = Exclude<Exhibit, DocumentExhibit>
export type CaseDocument = DocumentExhibit
export type EvidenceType = Evidence['type']
@@ -150,6 +159,58 @@ export interface BriefConcept {
export interface LevelBrief { body: string; concepts: BriefConcept[] }
export interface LevelGoal {
/** Present in author mode so the goal can be edited; omitted in play mode. */
id?: string
key: string
title: string
instructions: string
completionMessage: string
enabled?: boolean
/** Author-only success condition. Expected answer flags stay out of play payloads. */
requiredFlags?: string[]
status: 'pending' | 'complete'
completedAt?: string
/** True only in the response to the mutation that completed this goal. */
newlyCompleted: boolean
}
export type CaseReportSubmissionStatus = 'evidence_insufficient' | 'evidence_accepted_report_incomplete' | 'accepted'
export interface CaseReportEvidence {
connectionId: string
documentExhibitId: string
displayNumber: number
documentTitle: string
fileType: SourceFileType
relationText: string
publishedAt?: string
sourceCitation?: string
sourceUri?: string
evidenceAccepted: boolean
}
export interface CaseReportClaim {
claimExhibitId: string
statement: string
evidence: CaseReportEvidence[]
}
export interface CaseReport {
title: string
investigatorName: string
requiredForCompletion: boolean
status: 'draft' | CaseReportSubmissionStatus
feedback?: string
issues: string[]
claims: CaseReportClaim[]
}
export interface CaseReportSubmissionInput {
investigatorName: string
evidence: Array<Pick<CaseReportEvidence, 'connectionId' | 'documentExhibitId' | 'relationText' | 'publishedAt' | 'sourceCitation' | 'sourceUri'>>
}
export interface CaseState {
id: string
title: string
@@ -160,6 +221,8 @@ export interface CaseState {
views: BoardView[]
viewport: Viewport
brief: LevelBrief
goals: LevelGoal[]
report?: CaseReport
revision: number
updatedAt?: string
levelStatus?: string
@@ -185,6 +248,8 @@ export interface EvidenceMatchAnchorDefinition {
export interface EvidenceMatchRuleDefinition {
id: string
name: string
sourceLabel?: string
sourceUri?: string
flagKey: string
matcherVersion: 'char_trigram_v1'
minimumAnchorMatches: number
@@ -192,8 +257,35 @@ export interface EvidenceMatchRuleDefinition {
anchors: EvidenceMatchAnchorDefinition[]
}
export interface EvidenceSemanticRuleDefinition {
id: string
goalId: string
goalKey: string
name: string
targetSubject: string
relatedSubject?: string
assertion: string
successFlagKey: string
relatedFlagKey?: string
minimumConfidence: number
evaluatorVersion: string
enabled: boolean
}
export interface DocumentSemanticAnalysis {
status: 'not_needed' | 'unavailable' | 'pending' | 'succeeded' | 'failed'
subject?: 'target' | 'related' | 'ambiguous' | 'neither'
supportsClaim?: boolean
evidenceExcerpt?: string
confidence?: number
retryable: boolean
awardedFlags: string[]
goals: LevelGoal[]
}
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
export function isEvidenceExhibit(exhibit: Exhibit): exhibit is Evidence { return exhibit.type !== 'document' }
export function isFolderExhibit(exhibit: Exhibit): exhibit is FolderExhibit { return exhibit.type === 'folder' }
export function isEventExhibit(exhibit: Exhibit): exhibit is EventExhibit { return exhibit.type === 'event' }
export function isPartyExhibit(exhibit: Exhibit): exhibit is PartyExhibit { return exhibit.type === 'party' }
export function isClaimExhibit(exhibit: Exhibit): exhibit is ClaimExhibit { return exhibit.type === 'claim' }