import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' import { audio } from './audio' import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import type { PlaythroughState } from './narrative' // three.js stays out of the board bundle until the player opens the inventory. const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory }))) const BOARD_W = 2400 const BOARD_H = 1500 const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [ 'image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file', ].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label })) function uid(_prefix: string) { return crypto.randomUUID() } function screenshotFile(file: File, index: number) { const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png' const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19) return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() }) } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function documentSearchText(document: CaseDocument) { return [document.title, document.fileType, document.captureKind,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() } function clippingInsetRotation(id:string) { let hash=0 for (const character of id) hash=(hash * 31 + character.charCodeAt(0)) >>> 0 const degrees=((hash % 49) - 24) / 10 return degrees === 0 ? .7 : degrees } function connectionPoint(item: Exhibit) { return exhibitWidget(item.type).connectionPorts(item)[0] } type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; exhibitId: string } const placement = (x: number, y: number, width: number, height: number) => ({ x, y, width, height, rotation: 0, zIndex: 1, hidden: false }) function replaceDirectedRelations(relations: ExhibitRelation[], type: 'supports' | 'concerns', fromExhibitId: string, targets: string[]) { const retained = relations.filter(relation => relation.type !== type || relation.fromExhibitId !== fromExhibitId) return [...retained, ...targets.map((toExhibitId, sortOrder): ExhibitRelation => ({ id: relations.find(relation => relation.type === type && relation.fromExhibitId === fromExhibitId && relation.toExhibitId === toExhibitId)?.id || uid(type), type, fromExhibitId, toExhibitId, sortOrder, }))] } export function App() { const [caseState, setCaseState] = useState(null) const [isAdmin, setIsAdmin] = useState(false) const [noLevels, setNoLevels] = useState(false) const [openDoc, setOpenDoc] = useState(null) const [selected, setSelected] = useState(null) const [linkFrom, setLinkFrom] = useState(null) const [docsOpen, setDocsOpen] = useState(false) const [documentQuery, setDocumentQuery] = useState('') const [helpOpen, setHelpOpen] = useState(false) const [adminMenuOpen, setAdminMenuOpen] = useState(false) const [status, setStatus] = useState('CONNECTING TO ARCHIVE…') const [clock, setClock] = useState('') const [draggingFiles, setDraggingFiles] = useState(false) const [uploading, setUploading] = useState(0) const [documentClassificationQueue, setDocumentClassificationQueue] = useState([]) const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move') const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) const [editingEventId, setEditingEventId] = useState(null) const [newEventDraft, setNewEventDraft] = useState(null) const [editingPartyId, setEditingPartyId] = useState(null) const [newPartyDraft, setNewPartyDraft] = useState(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(null) const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState(null) const [threadDraft, setThreadDraft] = useState(null) const [flagsOpen, setFlagsOpen] = useState(false) const [inventoryOpen, setInventoryOpen] = useState(false) const [matchRulesOpen, setMatchRulesOpen] = useState(false) const [arrivingExhibitIds, setArrivingExhibitIds] = useState([]) const [activePlaythroughId, setActivePlaythroughId] = useState(null) const [completedGoal, setCompletedGoal] = useState(null) const [advancing, setAdvancing] = useState(false) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) const adminMenuRef = useRef(null) const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1' const adminRoute = window.location.pathname === '/admin' const loadLevelBySlug = useCallback(async (slug: string, editQuery = '') => { const response = await fetch(`/api/levels/${encodeURIComponent(slug)}${editQuery}`) if (!response.ok) throw new Error('Level unavailable') const data = normalizeCase(await response.json()) setCaseState(data) const arrivals = data.newlyVisibleDocumentIds || [] if (arrivals.length) { setArrivingExhibitIds(arrivals) void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }), }) } return data }, []) 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)); 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.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 () => { if (deepLinkLevel) { await openLevel(deepLinkLevel); return } const levels = await (await fetch('/api/levels')).json() as { id: string }[] if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return } await openLevel(levels[0].id) } boot().catch(async () => { try { const levels = await (await fetch('/api/levels')).json() as { id: string }[] if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return } await openLevel(levels[0].id) } catch { const cached = localStorage.getItem('gupi-osint-board:last') if (cached) setCaseState(normalizeCase(JSON.parse(cached))) setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE') } }) const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })) tick(); const timer = window.setInterval(tick, 30000) return () => clearInterval(timer) }, [loadLevelBySlug, adminRoute]) useEffect(() => { if (!adminMenuOpen) return const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) } document.addEventListener('pointerdown', close) return () => document.removeEventListener('pointerdown', close) }, [adminMenuOpen]) useEffect(() => { if (!recentlyCreatedExhibitId) return const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400) return () => window.clearTimeout(timer) }, [recentlyCreatedExhibitId]) useEffect(() => { if (!recentlyCreatedConnectionId) return const timer = window.setTimeout(() => setRecentlyCreatedConnectionId(null), 1200) return () => window.clearTimeout(timer) }, [recentlyCreatedConnectionId]) useEffect(() => { if (!arrivingExhibitIds.length) return const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800) return () => window.clearTimeout(timer) }, [arrivingExhibitIds]) const update = useCallback((fn: (state: CaseState) => CaseState) => { setCaseState(current => { if (!current) return current const next = fn(current) localStorage.setItem('gupi-osint-board:last', JSON.stringify(next)) window.clearTimeout(saveTimer.current) saveTimer.current = window.setTimeout(() => { const editQuery = requestedEditMode && next.editingAllowed ? '?edit=1' : '' fetch(`/api/levels/${encodeURIComponent(next.id)}${editQuery}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(next) }) .then(r => { if (!r.ok) throw new Error(); setStatus('SAVED TO CASE ARCHIVE') }) .catch(() => setStatus('OFFLINE · SAVED LOCALLY')) }, 450) return next }) }, [requestedEditMode]) const focusEvidence = (id: string) => { if (!caseState) return const ev = caseState.exhibits.find(e => e.id === id) if (!ev) return setSelected(id) update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } })) } const extract = (doc: CaseDocument, regionId: string) => { if (!caseState) return const region = doc.regions.find(r => r.id === regionId)! const existingSource = caseState.relations.find(relation => relation.type === 'source' && relation.toExhibitId === doc.id && relation.sourceRegionId === regionId) if (existingSource) { setOpenDoc(null); focusEvidence(existingSource.fromExhibitId); return } const ev: FolderExhibit = { id: uid('folder'), type: 'folder', title: `${documentWidget(doc.fileType).label.toUpperCase()} EVIDENCE`, content: region.excerpt, isOpen: false, ...placement(850 + Math.random() * 220, 390 + Math.random() * 250, 260, 166), } update(s => ({ ...s, exhibits: [...s.exhibits, ev], relations: [...s.relations, { id: uid('contains'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'contains', sortOrder: 0 }, { id: uid('source'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'source', sortOrder: 0, sourceRegionId: region.id }, ] })) setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } const addNote = (preset?: string, presentation:NotePresentation = 'luggage') => { const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim() if (!content || !caseState) return const { viewport } = caseState const size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 } const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width:size.width }) const note: Evidence = { id: uid('note'), type:'note', title:presentation === 'lined_sheet' ? 'FIELD NOTE' : 'WORKING NOTE', content,presentation, ...placement(position.x,position.y,size.width,size.height) } update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) } const addEvent = () => { if (!caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 }) const event: EventExhibit = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', ...placement(position.x, position.y, 270, 174) } setNewEventDraft(event) } const addParty = () => { if (!caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), }, { width: 280 }) setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], ...placement(position.x, position.y, 280, 190) }) } const classifyConcept = (conceptId: string, partyKind: PartyKind) => { if (!caseState) return const concept = caseState.brief.concepts.find(item => item.id === conceptId) if (!concept) return const existingId = concept.resolvedPartyExhibitId const partyId = existingId || uid('party') const { viewport } = caseState const existingParty = caseState.exhibits.find((item): item is PartyExhibit => item.id === existingId && item.type === 'party') const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), }, { width: 280 }) const party: PartyExhibit = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined, title: concept.label, content: concept.context, aliases: [], ...placement(position.x, position.y, 280, 190) } update(state => ({ ...state, exhibits: existingId ? state.exhibits.map(item => item.id === existingId && item.type === 'party' ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.exhibits, party], brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) }, })) setSelected(partyId) setRecentlyCreatedExhibitId(partyId) setStatus(`${partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`) } const closeBrief = () => { if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString()) setBriefOpen(false) } const handleCardClick = (id: string) => { if (!linkFrom) { setSelected(current => current === id ? null : id); return } completeThread(id) } const completeThread = (targetId: string) => { if (!linkFrom || !caseState || linkFrom === targetId) return const existing = caseState.connections.find(connection => (connection.fromExhibitId === linkFrom && connection.toExhibitId === targetId) || (connection.fromExhibitId === targetId && connection.toExhibitId === linkFrom)) if (existing) { setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') return } const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom) const target = caseState.exhibits.find(exhibit => exhibit.id === targetId) setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) setLinkFrom(null) if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId) } const saveThread = (connection: Connection) => { if (!caseState) return const exists = caseState.connections.some(item => item.id === connection.id) update(state => ({ ...state, connections: exists ? state.connections.map(item => item.id === connection.id ? connection : item) : [...state.connections, connection] })) if (!exists) setRecentlyCreatedConnectionId(connection.id) setThreadDraft(null) setStatus(connection.label ? 'RED THREAD TAGGED' : 'RED THREAD TIGHTENED') } const removeThread = (id: string) => { update(state => ({ ...state, connections: state.connections.filter(connection => connection.id !== id) })) setThreadDraft(null) setStatus('RED THREAD REMOVED') } const removeExhibit = (id: string) => { update(state => discardExhibit(state, id)) setSelected(current => current === id ? null : current) setLinkFrom(current => current === id ? null : current) setEditingFolderId(current => current === id ? null : current) setEditingEventId(current => current === id ? null : current) setEditingPartyId(current => current === id ? null : current) setRecentlyCreatedExhibitId(current => current === id ? null : current) setStatus('EXHIBIT DISCARDED · RELATIONS REMOVED') } const toggleThreadTool = () => { if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') } else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') } setBoardTool('move') } const reset = async () => { if (!caseState?.sourceTemplateVersionId || !window.confirm('Reset this investigation to its original template version?')) return const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' }) if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') } } const saveAsTemplate = async () => { if (!caseState || !requestedEditMode || !caseState.editingAllowed) return const name = window.prompt('Template name:', caseState.title)?.trim() if (!name) return window.clearTimeout(saveTimer.current) setStatus('FREEZING TEMPLATE VERSION…') const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}?edit=1`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(caseState), }) if (!saved.ok) { setStatus('LEVEL SAVE FAILED'); return } const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), }) if (!response.ok) { setStatus('TEMPLATE SAVE FAILED'); return } const template: { slug: string; currentVersion: number } = await response.json() setStatus(`TEMPLATE ${template.slug.toUpperCase()} · VERSION ${template.currentVersion}`) } const instantiateTemplate = async () => { if (!requestedEditMode || !caseState?.editingAllowed) return const templatesResponse = await fetch('/api/templates') if (!templatesResponse.ok) { setStatus('TEMPLATE ARCHIVE UNAVAILABLE'); return } const templates: { slug: string; name: string; currentVersion: number }[] = await templatesResponse.json() if (!templates.length) { setStatus('NO TEMPLATES SAVED'); return } const templateSlug = window.prompt(`Template slug:\n${templates.map(item => `${item.slug} (v${item.currentVersion})`).join('\n')}`, templates[0].slug)?.trim() if (!templateSlug) return const title = window.prompt('Name the new investigation:', templates.find(item => item.slug === templateSlug)?.name || 'New Investigation')?.trim() if (!title) return setStatus('CLONING TEMPLATE…') const response = await fetch(`/api/templates/${encodeURIComponent(templateSlug)}/levels?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }), }) if (!response.ok) { setStatus('TEMPLATE CLONE FAILED'); return } const level: CaseState = await response.json() window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) } const enterLevelEditor = () => { const params = new URLSearchParams(window.location.search) params.set('edit', '1') window.location.assign(`${window.location.pathname}?${params.toString()}`) } const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => { if (!caseState) return const queue = Array.from(files) setUploading(queue.length) setDraggingFiles(false) for (const [queueIndex, file] of queue.entries()) { const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24, y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24, }, { width: 174, height: 145 }) const form = new FormData() form.append('file', file) 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() const { analysis, ...document } = uploaded update(s => { return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] } }) setSelected(document.id) setArrivingExhibitIds(current => [...new Set([...current, document.id])]) 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(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 (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()}`) if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])]) } catch (error) { setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') } finally { setUploading(count => count - 1) } } }, [caseState, loadLevelBySlug, requestedEditMode, update]) const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => { const presentation=documentCapture(captureKind) const initialClipRotation=Math.round((Math.random() * 20 - 10) * 10) / 10 const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height, title:captureKind === 'clipping' && document.captureKind === 'unclassified' && document.title === document.fileName ? '' : document.title, rotation:captureKind === 'clipping' && document.captureKind === 'unclassified' ? initialClipRotation : document.rotation }) update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document' ? classify(exhibit) : exhibit) })) setOpenDoc(current => current?.id === documentId ? classify(current) : current) setDocumentClassificationQueue(current => current.filter(id => id !== documentId)) setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`) } 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('/?resume=1') // resume straight into the next node, not the splash } 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) => { const images = Array.from(event.clipboardData?.items || []).flatMap(item => { const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null return file ? [file] : [] }) if (!images.length) return event.preventDefault() void uploadFiles(images.map(screenshotFile), 'clipboard') } window.addEventListener('paste', handlePaste) return () => window.removeEventListener('paste', handlePaste) }, [caseState, uploadFiles]) if (adminRoute) return if (noLevels) return { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (!caseState) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status}
const documents = documentExhibits(caseState.exhibits) const evidence = evidenceExhibits(caseState.exhibits) const documentById = new Map(documents.map(document => [document.id, document])) const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase() const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null 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 => { const widget=exhibitWidget(exhibit.type) if (!widget) throw new Error(`No widget is registered for exhibit type “${String(exhibit.type)}”`) return widget.temporalFacts(exhibit).map(fact => { const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}` return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id } }) }).sort((a, b) => dateValue(a.date) - dateValue(b.date)) const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => { if (!a.eventDate) return b.eventDate ? 1 : 0 if (!b.eventDate) return -1 return dateValue(a.eventDate) - dateValue(b.eventDate) }) const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') return
{inventoryOpen && activePlaythroughId && window.location.assign('/?resume=1') }} onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} /> }
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
{status}{clock}
{ 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) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{activeGoal && }
{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
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 && item.type === 'party')} recentlyCreatedExhibitId={recentlyCreatedExhibitId} canEdit={canAuthor} onClose={closeBrief} onEdit={() => setEditingBrief(true)} onClassify={classifyConcept} onNewParty={addParty} onLocate={focusEvidence} onEditParty={setEditingPartyId} />} {storyEvents.length > 0 && } {!docsOpen && }
{Math.round(caseState.viewport.zoom * 100)}%
{draggingFiles &&
ADD SOURCE DOCUMENTSDROP FILES INTO THIS LEVEL
}
document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} /> `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/> {timelineView?.visible !== false && setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/> } {classificationDocument && classifyDocument(classificationDocument.id,captureKind)}/>} {openDoc && setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)} onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }} onType={captureKind => classifyDocument(openDoc.id,captureKind)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />} {editingFolderId && widget.id === editingFolderId && widget.type === 'folder')!} memberIds={containedIds(caseState, editingFolderId)} documents={documents} canManageContents={canAuthor} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, exhibits: state.exhibits.map(widget => widget.id === folder.id ? folder : widget), relations: [ ...state.relations.filter(relation => relation.type !== 'contains' || relation.fromExhibitId !== folder.id), ...members.map((documentId, index): ExhibitRelation => ({ id: state.relations.find(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id && relation.toExhibitId === documentId)?.id || uid('contains'), fromExhibitId: folder.id, toExhibitId: documentId, type: 'contains', sortOrder: index })), ], })) setEditingFolderId(null) setStatus('FOLDER UPDATED') }} />} {editingFileId && document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/> } {editingEventId && item.id === editingEventId && item.type === 'event')!} exhibits={caseState.exhibits} relations={caseState.relations} onClose={() => setEditingEventId(null)} onSave={(event, supports) => { update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === event.id ? event : item), relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} />} {newEventDraft && setNewEventDraft(null)} onSave={(event, supports) => { update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })) setNewEventDraft(null) setSelected(event.id) setRecentlyCreatedExhibitId(event.id) setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED') }} />} {editingPartyId && item.id === editingPartyId && item.type === 'party')!} exhibits={caseState.exhibits} relations={caseState.relations} onClose={() => setEditingPartyId(null)} onSave={(party, related) => { update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === party.id ? party : item), relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) })) setEditingPartyId(null) setStatus('PARTY DOSSIER UPDATED') }} />} {newPartyDraft && setNewPartyDraft(null)} onSave={(party, related) => { update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) })) setNewPartyDraft(null) setSelected(party.id) setRecentlyCreatedExhibitId(party.id) setStatus(`${party.partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`) }} />} {editingBrief && setEditingBrief(false)} onSave={brief => { update(state => ({ ...state, brief })) setEditingBrief(false) setStatus('LEVEL BRIEF UPDATED') }} />} {editingTimeline && item.date)} onClose={() => setEditingTimeline(false)} onSave={timelineRange => { update(state => ({ ...state, views: state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: timelineRange ? 'fixed' : 'auto', range: timelineRange || undefined } : view) })) setEditingTimeline(false) setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC') }} />} {threadDraft && item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'} targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'} isNew={!caseState.connections.some(item => item.id === threadDraft.id)} onClose={() => setThreadDraft(null)} onSave={saveThread} onRemove={() => removeThread(threadDraft.id)} />} {reportOpen && caseState.report && 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 && setHelpOpen(false)}/>} {flagsOpen && setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />} {matchRulesOpen && setMatchRulesOpen(false)}/>} {completedGoal && void continueAfterGoal()}/>}
} function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (level: CaseState) => void }) { const [creating, setCreating] = useState(false) const createLevel = async () => { const title = window.prompt('Name this investigation level:', 'Untitled Investigation')?.trim() if (!title) return setCreating(true) try { const response = await fetch('/api/levels', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }) }) if (!response.ok) throw new Error('Could not create level') onCreated(await response.json()) } finally { setCreating(false) } } return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) const pinchDistance = useRef(null) const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null) const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null) const [expandedThreadTagId, setExpandedThreadTagId] = useState(null) const [draggingThreadTagId, setDraggingThreadTagId] = useState(null) const [draggingWidget, setDraggingWidget] = useState(false) const [trashActive, setTrashActive] = useState(false) const trashRef = useRef(null) const trashTarget = useRef(false) const byId = useMemo(() => new Map(state.exhibits.map(exhibit => [exhibit.id, exhibit])), [state.exhibits]) const containmentRelations = state.relations.filter(relation => relation.type === 'contains') const pointForId = (id: string) => { const exhibit = byId.get(id) if (!exhibit) return undefined const membership = exhibit.type === 'document' ? containmentRelations.find(item => item.toExhibitId === id) : undefined const folder = membership ? byId.get(membership.fromExhibitId) : undefined return folder?.type === 'folder' && !folder.isOpen ? connectionPoint(folder) : connectionPoint(exhibit) } useEffect(() => { const board = boardRef.current if (!board) return const handleWheelZoom = (event: WheelEvent) => { event.preventDefault() event.stopPropagation() if (event.ctrlKey || event.metaKey || event.deltaY === 0) return const bounds = board.getBoundingClientRect() const anchor = { x: event.clientX - bounds.left, y: event.clientY - bounds.top } update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromWheel(s.viewport.zoom, event.deltaY), anchor) })) } board.addEventListener('wheel', handleWheelZoom, { passive: false }) return () => board.removeEventListener('wheel', handleWheelZoom) }, [boardRef, update]) const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget'; id: string }) => { if ((event.target as HTMLElement).closest('button')) return if (event.pointerType === 'touch') { touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (touchPoints.current.size >= 2) { if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer) folderLongPress.current = null const points = [...touchPoints.current.values()] pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) drag.current = null event.preventDefault() return } } const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? state.viewport.x, originY: widget?.y ?? state.viewport.y } setDraggingWidget(target?.kind === 'widget') try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } } const threadTagPointerDown = (event: React.PointerEvent, id: string) => { event.stopPropagation() if (event.button !== 0) return event.preventDefault() setDraggingThreadTagId(id) drag.current = { kind: 'thread-tag', id, startX: event.clientX, startY: event.clientY, originX: 0, originY: 0 } try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } } const trackThreadPointer = (event: React.PointerEvent) => { if (linkFrom && boardRef.current) { const bounds = boardRef.current.getBoundingClientRect() setThreadPointer({ x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }) } } const pointerMove = (event: React.PointerEvent) => { trackThreadPointer(event) const pendingFolderPress = folderLongPress.current if (pendingFolderPress?.pointerId === event.pointerId && Math.hypot(event.clientX - pendingFolderPress.startX, event.clientY - pendingFolderPress.startY) > 8) { window.clearTimeout(pendingFolderPress.timer) folderLongPress.current = null } if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) { touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (touchPoints.current.size >= 2) { const points = [...touchPoints.current.values()] const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) const previous = pinchDistance.current if (previous && boardRef.current) { const bounds = boardRef.current.getBoundingClientRect() const anchor = { x: (points[0].x + points[1].x) / 2 - bounds.left, y: (points[0].y + points[1].y) / 2 - bounds.top } update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromPinch(s.viewport.zoom, previous, distance), anchor) })) } pinchDistance.current = distance suppressClick.current = true event.preventDefault() return } } if (!drag.current) return const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true if (drag.current.kind === 'widget') { const bounds = trashRef.current?.getBoundingClientRect() const overTrash = Boolean(bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom) trashTarget.current = overTrash setTrashActive(overTrash) } if (drag.current.kind === 'thread-tag' && boardRef.current) { if (drag.current.moved) setExpandedThreadTagId(null) const connection = state.connections.find(item => item.id === drag.current!.id) const from = connection ? pointForId(connection.fromExhibitId) : undefined const to = connection ? pointForId(connection.toExhibitId) : undefined if (connection && from && to) { const bounds = boardRef.current.getBoundingClientRect() const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom } const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer) update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) })) } } else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === drag.current!.id ? { ...exhibit, ...next } : exhibit) } }) else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) })) } const finishDrag = (event: React.PointerEvent) => { if (folderLongPress.current?.pointerId === event.pointerId) { window.clearTimeout(folderLongPress.current.timer) folderLongPress.current = null } if (event.pointerType === 'touch') { touchPoints.current.delete(event.pointerId) if (touchPoints.current.size < 2) pinchDistance.current = null } const completedDrag = drag.current if (completedDrag) suppressClick.current = Boolean(completedDrag.moved) drag.current = null setDraggingThreadTagId(null) setDraggingWidget(false) setTrashActive(false) 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) })) const startFolderLongPress = (event: React.PointerEvent, id: string) => { if (event.pointerType !== 'touch' || tool !== 'move' || linkFrom || (event.target as HTMLElement).closest('button')) return if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer) const pointerId = event.pointerId const timer = window.setTimeout(() => { if (touchPoints.current.size !== 1 || folderLongPress.current?.pointerId !== pointerId) return folderLongPress.current = null drag.current = null trashTarget.current = false setDraggingWidget(false) setTrashActive(false) suppressClick.current = true toggleFolder(id) }, 520) folderLongPress.current = { pointerId, id, startX: event.clientX, startY: event.clientY, timer } } useEffect(() => () => { if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer) }, []) const widgetContext: ExhibitWidgetContext = { exhibits: state.exhibits, relations: state.relations, dispatch: (command: WidgetCommand) => { if (command.type === 'open-document') onOpenSource(command.documentId) else if (command.type === 'edit-folder') onEditFolder(command.folderId) else if (command.type === 'edit-event') onEditEvent(command.eventId) else if (command.type === 'edit-party') onEditParty(command.partyId) else if (command.type === 'edit-document') onEditFile(command.documentId) else if (command.type === 'update-memory-cue') onUpdateDocumentCue(command.documentId, command.cue) } } const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined return
{ const target = e.target as HTMLElement if (!target.closest('.thread-tag')) setExpandedThreadTagId(null) const emptyBoardDrag = e.button === 0 && !target.closest('.evidence-card, .source-file-widget, .thread-tag, button') if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) } }} onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return })} {previewOrigin && threadPointer && } {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return })} {state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? : null })} {state.relations.filter(relation => relation.type === 'concerns').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? : null })} {containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} {evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); const notePresentation=ev.type === 'note' ? ev.presentation === 'lined_sheet' ? 'lined-sheet' : 'luggage-tag' : ''; return
{ e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
{definition.heading(ev, widgetContext)}{String(i + 1).padStart(3, '0')}
})} {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 presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return
{ 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)}>
{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}
onUpdateDocumentCue(document.id, cue)}/>
{document.captureKind === 'photo' ? : document.captureKind !== 'clipping' ? {document.title} : null}
{document.captureKind === 'clipping' && {missingProvenance.length ? `ADD ${missingProvenance.join(' + ')}` : 'SOURCE RECORDED'}}
})}
} function MugshotCaption({ name }:{ name:string }) { const [visibleName,setVisibleName]=useState(name) const [writing,setWriting]=useState(false) const previousName=useRef(name) useEffect(() => { if (name === previousName.current) return previousName.current=name if (!name) { setVisibleName('');setWriting(false);return } const letterDelay=Math.max(48,Math.min(82,900 / name.length)) const duration=Math.round(letterDelay * name.length) let character=0 setVisibleName('');setWriting(true) const stopSound=audio.sharpie(duration + 80) const timer=window.setInterval(() => { character+=1 setVisibleName(name.slice(0,character)) if (character >= name.length) { window.clearInterval(timer);setWriting(false) } },letterDelay) return () => { window.clearInterval(timer);stopSound?.() } },[name]) return {visibleName} } function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) { const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null) useLayoutEffect(() => { if (!documentId) { setBeam(null); return } let frame = 0 let animateUntil = Date.now() + 500 const matchingElement = (attribute: 'documentRowId' | 'documentLocatorTarget') => Array.from(document.querySelectorAll(attribute === 'documentRowId' ? '[data-document-row-id]' : '[data-document-locator-target]')).find(element => element.dataset[attribute] === documentId) const measure = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(function measureFrame() { const source = matchingElement('documentRowId') const target = matchingElement('documentLocatorTarget') const viewport = document.querySelector('.board-viewport') if (!source || !target || !viewport) { setBeam(null); return } const from = source.getBoundingClientRect(), to = target.getBoundingClientRect(), bounds = viewport.getBoundingClientRect() const x1 = Math.min(from.right - 3, bounds.left - 3) const y1 = from.top + from.height / 2 const x2 = Math.max(bounds.left + 12, Math.min(bounds.right - 12, to.left + to.width / 2)) const y2 = Math.max(bounds.top + 12, Math.min(bounds.bottom - 12, to.top + to.height / 2)) const bend = Math.max(70, Math.abs(x2 - x1) * .32) setBeam({ path: `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`, x: x2, y: y2 }) if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame) }) } measure() const observer = new ResizeObserver(measure) const observed = [matchingElement('documentRowId'), matchingElement('documentLocatorTarget'), document.querySelector('.board-viewport')].filter((element): element is HTMLElement => Boolean(element)) observed.forEach(element => observer.observe(element)) const handleLayoutChange = () => { animateUntil = Date.now() + 500; measure() } window.addEventListener('resize', handleLayoutChange) document.querySelector('.doc-list')?.addEventListener('scroll', handleLayoutChange, { passive: true }) return () => { cancelAnimationFrame(frame) observer.disconnect() window.removeEventListener('resize', handleLayoutChange) document.querySelector('.doc-list')?.removeEventListener('scroll', handleLayoutChange) } }, [documentId, layoutKey]) if (!beam) return null return } function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) { const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([]) const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|') useLayoutEffect(() => { let frame = 0 let animateUntil = Date.now() + 500 const measure = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(function measureFrame() { const sources = new Map(Array.from(document.querySelectorAll('[data-temporal-id]')).map(element => [element.dataset.temporalId, element])) const markers = new Map(Array.from(document.querySelectorAll('[data-marker-id]')).map(element => [element.dataset.markerId, element])) setLines(items.flatMap(item => { const source = sources.get(item.sourceTemporalId), marker = markers.get(item.id) if (!source || !marker) return [] const from = source.getBoundingClientRect(), to = marker.getBoundingClientRect() const clip = source.closest('.board-viewport, .doc-list')?.getBoundingClientRect() const visibleLeft = Math.max(from.left, clip?.left ?? 0), visibleRight = Math.min(from.right, clip?.right ?? window.innerWidth) const visibleTop = Math.max(from.top, clip?.top ?? 0), visibleBottom = Math.min(from.bottom, clip?.bottom ?? window.innerHeight) if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) return [] return [{ id: item.id, x1: visibleLeft + (visibleRight - visibleLeft) / 2, y1: visibleBottom, x2: to.left + to.width / 2, y2: to.top + to.height / 2 }] })) if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame) }) } measure() const observer = new ResizeObserver(measure) document.querySelectorAll('.workspace, .timeline, [data-temporal-id], [data-marker-id]').forEach(element => observer.observe(element)) const handleResize = () => { animateUntil = Date.now() + 500; measure() } window.addEventListener('resize', handleResize) return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener('resize', handleResize) } }, [itemsKey, layoutKey]) return } function Timeline({ items, range, selected, onSelect, onEdit }: { items: TemporalItem[]; range?: TimelineRange | null; selected: string | null; onSelect: (item: TemporalItem) => void; onEdit: () => void }) { const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date), undefined, range || undefined) const position = (date: string) => timelinePositionPercent(date, { start, end }) const ticks = range ? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } }) : Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } }) return
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
} function localDateTime(value?: string) { if (!value) return '' if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00` const date = new Date(value) if (!Number.isFinite(date.getTime())) return '' const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) return local.toISOString().slice(0, 16) } function publishedDateParts(value?: string) { if (!value) return { date:'',time:'' } const parsed=new Date(value) if (!Number.isFinite(parsed.getTime())) return { date:'',time:'' } const iso=parsed.toISOString() const time=iso.slice(11,16) return { date:iso.slice(0,10),time:time === '00:00' ? '' : time } } function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) { const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort() const [start, setStart] = useState(range?.start || dated[0] || '') const [end, setEnd] = useState(range?.end || dated[dated.length - 1] || '') const valid = Boolean(start && end && end > start) return
{ event.preventDefault(); if (valid) onSave({ start, end }) }}>
Adjust timeline range
TEMPORAL VIEWPORT · BOARD SETTING

Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.

} function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubmit, onContinue }: { report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void onSubmit:(input:CaseReportSubmissionInput)=>Promise;onContinue:()=>void }) { const investigatorName=report.investigatorName || defaultInvestigator const [busy,setBusy]=useState(false) const [error,setError]=useState('') const submit=async (event:React.FormEvent) => { event.preventDefault();setBusy(true);setError('') try { await onSubmit({ investigatorName }) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') } finally { setBusy(false) } } return
void submit(event)}>
{report.title}
GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME

CASE REPORT

FORM GUPI–7 / EVIDENTIARY FINDING
{report.claims.map((claim,claimIndex) =>

CLAIM {claimIndex + 1}{claim.statement}

EVIDENCE

{claim.evidence.length === 0 ?
No source evidence is connected to this claim. Return to the board and use red thread to attach a document.
: claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return
Exhibit {item.displayNumber}{item.fileType.replaceAll('_',' ')} · {item.documentTitle}{item.evidenceAccepted ? CONTENT VERIFIED : rejected ? NOT VERIFIED : null}
{rejected &&

{item.verification.detail}

}
EVIDENTIARY STATEMENT

{item.relationText || 'No evidentiary statement attached.'}

DATED

{item.publishedAt?.slice(0,10) || 'NOT RECORDED'}

SOURCE / PUBLICATION

{item.sourceCitation || 'NOT RECORDED'}

SOURCE LINK · IF AVAILABLE{item.sourceUri ? {item.sourceUri} :

NOT RECORDED

}
})}
)}
INVESTIGATOR

{investigatorName}

{report.status !== 'draft' &&
{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}

{report.feedback}

} {error &&

{error}

}
{report.status === 'accepted' ? : }
} 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) const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage') const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50) const save = (tag = label) => { const lateralLimit = threadTagLateralLimit(tightness) onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) }) } return
{ event.preventDefault(); save() }}>
{isNew ? 'Add relation tag' : 'Edit red thread'}
RED THREAD · INVESTIGATOR RELATION
{sourceName}{targetName}

What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.

TAG PRESENTATION
{!isNew && }{isNew && }
} 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 const pending = goals.filter(goal => goal.status === 'pending').length const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION' return } function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; hasNext: boolean; busy: boolean; onContinue: () => void }) { return
GU-NET SOURCE AUTHENTICATION
OBJECTIVE COMPLETE

{goal.title}

{goal.completionMessage || 'The submitted evidence satisfies this objective.'}

SOURCE
VERIFIED
} function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) { const [body, setBody] = useState(brief.body) const [concepts, setConcepts] = useState(brief.concepts) const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }]) return
{ submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
Edit level brief
AUTHORING · PLAYER CONCEPTS