Refine board notes, clipping evidence, and case reports
This commit is contained in:
+62
-31
@@ -1,6 +1,6 @@
|
||||
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, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
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'
|
||||
@@ -28,6 +28,12 @@ function documentSearchText(document: CaseDocument) {
|
||||
...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]
|
||||
}
|
||||
@@ -214,12 +220,14 @@ export function App() {
|
||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
}
|
||||
|
||||
const addNote = (preset?: string) => {
|
||||
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 position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) }
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -439,7 +447,10 @@ export function App() {
|
||||
|
||||
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||||
const presentation=documentCapture(captureKind)
|
||||
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height })
|
||||
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) }))
|
||||
@@ -529,12 +540,16 @@ export function App() {
|
||||
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 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))
|
||||
})
|
||||
}).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
|
||||
@@ -544,7 +559,7 @@ export function App() {
|
||||
return <main className="desktop">
|
||||
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||||
onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||
onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||
</Suspense>}
|
||||
<header className="menubar">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
@@ -949,20 +964,21 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||
{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 <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
||||
</svg>
|
||||
{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)); return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||
{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 <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${notePresentation} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||
onPointerDown={e => { 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) } }}>
|
||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||
<Widget exhibit={ev} context={widgetContext}/>
|
||||
</article>})}
|
||||
{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)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${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 }}
|
||||
{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)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${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,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
||||
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>{(document.captureKind === 'unclassified' ? definition.label : presentation.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>
|
||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : <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>
|
||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||||
<div className="source-file-footer"><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></div>
|
||||
</article> })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1088,6 +1104,15 @@ function localDateTime(value?: string) {
|
||||
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] || '')
|
||||
@@ -1106,21 +1131,15 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
||||
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 investigatorName=report.investigatorName || defaultInvestigator
|
||||
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 }) }
|
||||
try { await onSubmit({ investigatorName }) }
|
||||
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 GUPI–7 / EVIDENTIARY FINDING</span></div>
|
||||
@@ -1128,16 +1147,16 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
||||
<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)!; const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
||||
: claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} 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> : rejected ? <em className="rejected">NOT VERIFIED</em> : null}</div>
|
||||
{rejected && <p className="report-evidence-diagnostic">{item.verification.detail}</p>}
|
||||
<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>
|
||||
<div className="report-reference"><span>EVIDENTIARY STATEMENT</span><p>{item.relationText || 'No evidentiary statement attached.'}</p></div>
|
||||
<div className="report-fields"><div className="report-reference"><span>DATED</span><p>{item.publishedAt?.slice(0,10) || 'NOT RECORDED'}</p></div>
|
||||
<div className="report-reference"><span>SOURCE / PUBLICATION</span><p>{item.sourceCitation || 'NOT RECORDED'}</p></div></div>
|
||||
<div className="report-reference"><span>SOURCE LINK · IF AVAILABLE</span>{item.sourceUri ? <a href={item.sourceUri} target="_blank" rel="noreferrer">{item.sourceUri}</a> : <p>NOT RECORDED</p>}</div>
|
||||
</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>
|
||||
<div className="report-investigator report-reference"><span>INVESTIGATOR</span><p>{investigatorName}</p></div>
|
||||
{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'
|
||||
@@ -1324,14 +1343,19 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||
const [publishedDate,setPublishedDate]=useState(() => publishedDateParts(document.publishedAt).date)
|
||||
const [publishedTime,setPublishedTime]=useState(() => publishedDateParts(document.publishedAt).time)
|
||||
const [sourceCitation,setSourceCitation]=useState(document.sourceCitation || '')
|
||||
const [sourceUri,setSourceUri]=useState(document.sourceUri || '')
|
||||
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
||||
const publishedAt=publishedDate ? `${publishedDate}T${publishedTime || '00:00'}:00.000Z` : undefined
|
||||
const presentation=documentCapture(captureKind)
|
||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt,
|
||||
const resolvedTitle=title.trim() || (captureKind === 'clipping' ? sourceCitation.trim() : document.fileName || 'UNTITLED FILE')
|
||||
onSave({ ...document,title:resolvedTitle,fileType,captureKind,publishedAt,
|
||||
sourceCitation:sourceCitation.trim() || undefined,sourceUri:sourceUri.trim() || undefined,
|
||||
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||||
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||
@@ -1341,11 +1365,18 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
||||
<div className="file-editor-body">
|
||||
<small>SOURCE FILE WIDGET</small>
|
||||
<div className="file-editor-grid">
|
||||
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
||||
<label className="field"><span>TITLE · DEFAULTS TO SOURCE FOR CLIPS</span><input aria-label="Document title" value={title} placeholder={captureKind === 'clipping' ? 'Uses Source / Publication when blank' : ''} onChange={event => setTitle(event.target.value)}/></label>
|
||||
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="field"><span>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
|
||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||
<div className="file-editor-grid published-fields">
|
||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED DATE</span><input aria-label="Published date" type="date" value={publishedDate} onChange={event => { setPublishedDate(event.target.value); if (!event.target.value) setPublishedTime('') }}/></label>
|
||||
<label className="field"><span>TIME · OPTIONAL · UTC</span><input aria-label="Published time" type="time" step="60" disabled={!publishedDate} value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||
</div>
|
||||
<div className="file-editor-grid">
|
||||
<label className="field"><span>SOURCE / PUBLICATION</span><input aria-label="Source publication" value={sourceCitation} maxLength={1000} placeholder="e.g. Google Patents · GB695913A" onChange={event => setSourceCitation(event.target.value)}/></label>
|
||||
<label className="field"><span>SOURCE URL</span><input aria-label="Source URL" type="url" value={sourceUri} maxLength={2000} placeholder="https://…" onChange={event => setSourceUri(event.target.value)}/></label>
|
||||
</div>
|
||||
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('folder domain behavior', () => {
|
||||
|
||||
describe('exhibit disposal', () => {
|
||||
it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => {
|
||||
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
||||
const note = { ...folder,id:'note-1',type:'note' as const,title:'Working note',presentation:'luggage' as const }
|
||||
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
||||
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
||||
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
||||
|
||||
+5
-2
@@ -1,7 +1,9 @@
|
||||
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, NotePresentation, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||
|
||||
export interface BoardPoint { x: number; y: number }
|
||||
|
||||
function notePresentation(value: unknown): NotePresentation { return value === 'lined_sheet' ? 'lined_sheet' : 'luggage' }
|
||||
|
||||
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
||||
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
||||
@@ -250,6 +252,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
||||
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
||||
...(exhibit.type === 'note' ? { presentation: notePresentation(exhibit.presentation) } : {}),
|
||||
...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,
|
||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||
@@ -280,7 +283,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record<string, unknown> | undefined)?.open === true }
|
||||
if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined }
|
||||
if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] }
|
||||
return { ...common, type: 'note' }
|
||||
return { ...common, type: 'note', presentation:notePresentation(item.presentation) }
|
||||
})
|
||||
const derivedRelations: ExhibitRelation[] = [
|
||||
...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })),
|
||||
|
||||
@@ -103,7 +103,7 @@ export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition>
|
||||
...exhibit.regions.flatMap(region => region.date ? [{ id:`${exhibit.id}:region:${region.id}`,exhibitId:exhibit.id,kind:'region_date' as const,start:region.date,label:region.label }] : []),
|
||||
] : [],searchText:searchable,Component:DocumentWidget },
|
||||
note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities,
|
||||
heading:() => 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
|
||||
heading:exhibit => exhibit.type === 'note' && exhibit.presentation === 'lined_sheet' ? 'FIELD NOTE / TORN PAGE' : 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
|
||||
event: { modelKind:'exhibit',visualType:'event',shell:'card',defaultSize:{width:270,height:174},capabilities:standardCapabilities,
|
||||
heading:() => 'EVENT / THIS HAPPENED',connectionPorts:standardPorts,temporalFacts:exhibit => exhibit.type === 'event' && exhibit.eventDate
|
||||
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
|
||||
@@ -144,7 +144,7 @@ export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureD
|
||||
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
||||
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
|
||||
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
||||
clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} },
|
||||
clipping:{ label:'Clip',description:'An extract mounted on a physical evidence card.',defaultSize:{width:230,height:290} },
|
||||
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
||||
}
|
||||
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
||||
|
||||
+41
-21
@@ -166,7 +166,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
|
||||
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
|
||||
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
|
||||
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
||||
.source-file-footer > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
||||
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
||||
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
||||
|
||||
@@ -214,32 +214,36 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
|
||||
.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
|
||||
@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
|
||||
.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; }
|
||||
.source-file-widget.capture-kind-photo .source-file-footer > time { padding-right: 3px; text-align: right; }
|
||||
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
||||
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
||||
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
||||
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
||||
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
||||
.source-file-widget.capture-kind-clipping { padding: 9px 12px 11px; background: linear-gradient(103deg,#e7e2d3,#d9d2bf); clip-path: polygon(1% 2%,99% 0,98% 13%,100% 28%,98% 43%,100% 61%,98% 78%,99% 98%,84% 99%,68% 97%,52% 100%,35% 98%,19% 100%,1% 98%,2% 79%,0 62%,2% 45%,0 27%); }
|
||||
.source-file-widget.capture-kind-clipping.open { transform: scale(1) rotate(.8deg); }
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview { height: 105px; border-color: #b2a996; box-shadow: none; }
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit: cover; filter: grayscale(.12) contrast(1.08); }
|
||||
.source-file-widget.capture-kind-clipping > strong { font-size: 11px; }
|
||||
.source-file-widget.capture-kind-clipping { padding:14px 13px 10px;overflow:visible;background:linear-gradient(105deg,#e2dfcf,#d3d1c2);border:2px solid #b99a68;box-shadow:8px 10px 0 #020b0980,0 0 0 2px #526059; }
|
||||
.source-file-widget.capture-kind-clipping::after { content:'';position:absolute;z-index:5;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; }
|
||||
.source-file-widget.capture-kind-clipping.open { transform:scale(1); }
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview { width:100%;height:218px;margin:3px 0 10px;border:2px solid #f4efe1;background:#c8c2b3;box-shadow:2px 3px 3px #10151170;transform:rotate(var(--clip-inset-rotation,.7deg));transform-origin:50% 48%; }
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit:contain;filter:grayscale(.12) contrast(1.08); }
|
||||
.source-file-widget.capture-kind-clipping .source-file-footer { display:flex;align-items:flex-end;justify-content:space-between;gap:8px; }
|
||||
.source-file-widget.capture-kind-clipping .source-file-footer > time { flex:0 0 auto;margin:0 0 2px;color:#775332; }
|
||||
.source-file-widget.capture-kind-clipping .source-file-actions { flex:1;justify-content:flex-end;gap:9px;margin-top:0; }
|
||||
.source-file-widget.capture-kind-clipping .source-file-actions button { color:#4e5d57;text-shadow:none; }
|
||||
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
||||
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
||||
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
||||
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
||||
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
||||
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
||||
.evidence-card.note header i { display: none; }
|
||||
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
||||
.evidence-card.note h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
|
||||
.evidence-card.note p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
|
||||
.evidence-card.note.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
||||
.evidence-card.note.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
||||
.evidence-card.note.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
||||
.evidence-card.note.selected h3 { font-size: 7px; }
|
||||
.evidence-card.note.luggage-tag { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
||||
.evidence-card.note.luggage-tag header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
||||
.evidence-card.note.luggage-tag header i { display: none; }
|
||||
.evidence-card.note.luggage-tag .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
||||
.evidence-card.note.luggage-tag h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
|
||||
.evidence-card.note.luggage-tag p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
|
||||
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
||||
.evidence-card.note.luggage-tag.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
||||
.evidence-card.note.luggage-tag.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
||||
.evidence-card.note.luggage-tag.selected h3 { font-size: 7px; }
|
||||
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
||||
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
||||
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
||||
@@ -351,6 +355,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.file-editor-body { padding: 24px 27px 22px; }
|
||||
.file-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
|
||||
.file-editor-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
|
||||
.file-editor-grid.published-fields { grid-template-columns:minmax(0,1fr) 180px; }
|
||||
.field > span { display: flex; align-items: center; gap: 6px; }
|
||||
.metadata-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
|
||||
.metadata-heading > div { display: grid; gap: 3px; }
|
||||
@@ -380,8 +385,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.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.rejected { border-left-color:#913d35;background:#e5d2c7cc; }
|
||||
.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-heading em.rejected { border-color:#98534b;color:#813a34; }.report-evidence-diagnostic { margin:-3px 0 13px;padding:9px 10px;border:1px solid #b78376;background:#ead8cc;color:#6f302d;font:9px/1.5 IBM Plex Mono; }
|
||||
.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-reference { display:grid; gap:4px; margin-top:9px; }.report-reference > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-reference > p,.report-reference > a { width:100%; min-width:0; min-height:31px; margin:0; padding:7px 8px; border-bottom:1px solid #747b75; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; overflow-wrap:anywhere; }.report-reference > a { color:#315b52; text-decoration-thickness:1px; text-underline-offset:3px; }
|
||||
.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 p { 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; }
|
||||
@@ -806,8 +811,23 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
||||
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
||||
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||
/* A note torn to the board reads as handwriting too. */
|
||||
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||
/* Ordinary board notes retain the small luggage-tag treatment. */
|
||||
.evidence-card.note.luggage-tag .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||
/* Notebook pages stay recognizably part of the notebook after being torn out. */
|
||||
.evidence-card.note.lined-sheet { box-sizing:border-box; min-height:270px; padding:18px 20px 20px 43px; overflow:hidden; color:#26356b;
|
||||
border:1px solid #e8dec5; background:
|
||||
linear-gradient(90deg,transparent 0 29px,#c8787880 30px,#c8787880 31px,transparent 32px),
|
||||
repeating-linear-gradient(180deg,#f4ecd6 0 26px,#b3c1c777 27px,#f4ecd6 28px);
|
||||
clip-path:polygon(0 0,100% 0,100% 97.5%,97% 99%,93% 98%,89% 100%,84% 98.5%,79% 100%,74% 98%,68% 99.5%,62% 98%,56% 100%,50% 98.5%,44% 100%,38% 98%,32% 99.5%,26% 98%,20% 100%,14% 98.5%,8% 100%,3% 98%,0 99%);
|
||||
box-shadow:8px 10px 5px #020b0980,0 0 0 1px #6c756f; }
|
||||
.evidence-card.note.lined-sheet::before { content:'';position:absolute;left:8px;top:19px;bottom:23px;width:10px;background:radial-gradient(circle at 50% 8px,#34433e 0 3px,#d7ceb8 3.5px 5px,transparent 5.5px) 0 0/10px 34px repeat-y;opacity:.82; }
|
||||
.evidence-card.note.lined-sheet::after { display:none; }
|
||||
.evidence-card.note.lined-sheet header { height:20px;padding-bottom:4px;border-color:#9ba8aa;color:#6a706b;font-size:7px; }
|
||||
.evidence-card.note.lined-sheet header i { display:none; }
|
||||
.evidence-card.note.lined-sheet .card-content { height:214px;overflow:hidden; }
|
||||
.evidence-card.note.lined-sheet h3 { margin:8px 0 5px;color:#805748;font:600 7px IBM Plex Mono;letter-spacing:.12em; }
|
||||
.evidence-card.note.lined-sheet .card-content p { display:block;margin:0;overflow:hidden;color:#26356b;font:24px/28px "Reenie Beanie",cursive;white-space:pre-wrap; }
|
||||
.evidence-card.note.lined-sheet.selected { outline:2px solid #e49a4a;outline-offset:5px;filter:drop-shadow(7px 9px 4px #0007); }
|
||||
|
||||
/* === Player auth + character screen (src/play.tsx) ================= */
|
||||
.auth-plate { gap: 4px; }
|
||||
|
||||
+3
-1
@@ -67,9 +67,12 @@ export interface UploadedCaseDocument extends DocumentExhibit {
|
||||
analysis: DocumentUploadAnalysis
|
||||
}
|
||||
|
||||
export type NotePresentation = 'luggage' | 'lined_sheet'
|
||||
|
||||
export interface NoteExhibit extends ExhibitBase {
|
||||
type: 'note'
|
||||
content: string
|
||||
presentation: NotePresentation
|
||||
}
|
||||
|
||||
export interface EventExhibit extends ExhibitBase {
|
||||
@@ -221,7 +224,6 @@ export interface CaseReport {
|
||||
|
||||
export interface CaseReportSubmissionInput {
|
||||
investigatorName: string
|
||||
evidence: Array<Pick<CaseReportEvidence, 'connectionId' | 'documentExhibitId' | 'relationText' | 'publishedAt' | 'sourceCitation' | 'sourceUri'>>
|
||||
}
|
||||
|
||||
export interface CaseState {
|
||||
|
||||
Reference in New Issue
Block a user