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

This commit is contained in:
2026-08-23 21:37:30 +02:00
6 changed files with 26 additions and 32 deletions
+6 -5
View File
@@ -28,17 +28,18 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
await page.goto('/?level=e2e-level&edit=1') await page.goto('/?level=e2e-level&edit=1')
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toBeVisible() await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toBeVisible()
expect((await page.locator('.menubar nav > button, .menubar nav > .admin-menu > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP', 'ADMIN']) expect((await page.locator('.menubar nav > button, .menubar nav > .admin-menu > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'BRIEF', 'HELP', 'ADMIN'])
await expect(page.getByRole('button', { name: 'Timeline settings', exact: true })).toBeVisible()
const temporalLayering = await page.evaluate(() => ({ const temporalLayering = await page.evaluate(() => ({
links: Number.parseInt(getComputedStyle(document.querySelector('.temporal-links')!).zIndex, 10), links: Number.parseInt(getComputedStyle(document.querySelector('.temporal-links')!).zIndex, 10),
timeline: Number.parseInt(getComputedStyle(document.querySelector('.timeline')!).zIndex, 10), timeline: Number.parseInt(getComputedStyle(document.querySelector('.timeline')!).zIndex, 10),
})) }))
expect(temporalLayering.links).toBeGreaterThan(temporalLayering.timeline) expect(temporalLayering.links).toBeGreaterThan(temporalLayering.timeline)
expect(await page.locator('.timeline .year').first().evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(12) expect(await page.locator('.timeline .year').first().evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(12)
expect(await page.locator('.timeline-label button').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11) expect(await page.locator('.timeline-range').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11)
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/) await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
await expect(page.locator('.brief-panel')).toBeVisible() await expect(page.locator('.brief-panel')).toBeVisible()
await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2') await expect(page.getByRole('button', { name: 'Brief', exact: true })).toContainText('2')
await page.getByRole('button', { name: 'Minimize brief', exact: true }).click() await page.getByRole('button', { name: 'Minimize brief', exact: true }).click()
await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/) await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/)
await expect(page.locator('.brief-panel > p')).toBeHidden() await expect(page.locator('.brief-panel > p')).toBeHidden()
@@ -157,7 +158,7 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
await boardViewport.dispatchEvent('pointerup', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y }) await boardViewport.dispatchEvent('pointerup', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointerup', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y }) await boardViewport.dispatchEvent('pointerup', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
await page.getByRole('button', { name: 'Case brief', exact: true }).click() await page.getByRole('button', { name: 'Brief', exact: true }).click()
await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace') await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace')
const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' }) const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' })
await waitForSave(page, () => personConcept.getByRole('button', { name: 'PERSON', exact: true }).click()) await waitForSave(page, () => personConcept.getByRole('button', { name: 'PERSON', exact: true }).click())
@@ -265,6 +266,6 @@ test('hides the admin menu without a verified admin JWT', async ({ browser }) =>
await page.goto('/?level=e2e-level&edit=1') await page.goto('/?level=e2e-level&edit=1')
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toHaveCount(0) await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toHaveCount(0)
expect((await page.locator('.menubar nav > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP']) expect((await page.locator('.menubar nav > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'BRIEF', 'HELP'])
await context.close() await context.close()
}) })
+5 -5
View File
@@ -17,7 +17,7 @@ async function waitForSave(page: Page, action: () => Promise<void>) {
} }
async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) { async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) {
if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Case brief', exact: true }).click() if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Brief', exact: true }).click()
const concept = page.locator('.brief-concepts section').filter({ hasText: name }) const concept = page.locator('.brief-concepts section').filter({ hasText: name })
await waitForSave(page, () => concept.getByRole('button', { name: kind, exact: true }).click()) await waitForSave(page, () => concept.getByRole('button', { name: kind, exact: true }).click())
await expect(page.locator('.brief-panel')).toBeVisible() await expect(page.locator('.brief-panel')).toBeVisible()
@@ -50,7 +50,7 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
await page.goto(`/?level=${playable.id}`) await page.goto(`/?level=${playable.id}`)
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible() await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/) await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
await page.locator('.open-files').click() await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click()
await page.getByRole('searchbox', { name: 'Search inside documents', exact: true }).fill('Elias') await page.getByRole('searchbox', { name: 'Search inside documents', exact: true }).fill('Elias')
await expect(page.locator('.doc-row')).toHaveCount(1) await expect(page.locator('.doc-row')).toHaveCount(1)
await expect(page.locator('.doc-row')).toContainText('Carrier Dispatch Manifest · H&F 14') await expect(page.locator('.doc-row')).toContainText('Carrier Dispatch Manifest · H&F 14')
@@ -59,18 +59,18 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
await page.getByRole('button', { name: 'Close documents', exact: true }).click() await page.getByRole('button', { name: 'Close documents', exact: true }).click()
await expect(page.locator('.evidence-card.folder')).toHaveCount(3) await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
await expect(page.locator('.timeline .marker')).toHaveCount(8) await expect(page.locator('.timeline .marker')).toHaveCount(8)
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31') await expect(page.locator('.timeline-range')).toHaveText('1987-10-01 — 1987-10-31')
const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox() const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox()
const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox() const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox()
expect(dispatchMarker).not.toBeNull() expect(dispatchMarker).not.toBeNull()
expect(auctionMarker).not.toBeNull() expect(auctionMarker).not.toBeNull()
expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100) expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100)
await page.getByRole('button', { name: 'TIMELINE', exact: true }).click() await page.getByRole('button', { name: 'Timeline settings', exact: true }).click()
await page.getByLabel('Timeline start date').fill('1987-10-10') await page.getByLabel('Timeline start date').fill('1987-10-10')
await page.getByLabel('Timeline end date').fill('1987-10-26') await page.getByLabel('Timeline end date').fill('1987-10-26')
await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click()) await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click())
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-10 — 1987-10-26') await expect(page.locator('.timeline-range')).toHaveText('1987-10-10 — 1987-10-26')
await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.') await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.')
await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.') await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.')
+1 -1
View File
@@ -94,7 +94,7 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
await page.getByLabel('Thread tag').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.') await page.getByLabel('Thread tag').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click() await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click()
await page.getByRole('button',{ name:'Case report' }).click() await page.getByRole('button',{ name:'Report' }).click()
await expect(page.locator('.case-report')).toBeVisible() await expect(page.locator('.case-report')).toBeVisible()
await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor') await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor')
await expect(page.locator('.report-evidence')).toContainText('Exhibit 1') await expect(page.locator('.report-evidence')).toContainText('Exhibit 1')
+5 -8
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 { 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, Settings2, 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 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 { AdminPanel } from './admin'
import { audio } from './audio' import { audio } from './audio'
@@ -549,7 +549,6 @@ export function App() {
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0 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 canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => { const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
const widget=exhibitWidget(exhibit.type) const widget=exhibitWidget(exhibit.type)
@@ -576,9 +575,8 @@ export function App() {
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div> <div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav> <nav>
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button> <button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button> <button className={briefOpen ? 'active' : ''} aria-label="Brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>} {caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
{activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>} {activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
<button onClick={() => setHelpOpen(true)}>HELP</button> <button onClick={() => setHelpOpen(true)}>HELP</button>
{isAdmin && <div className="admin-menu" ref={adminMenuRef}> {isAdmin && <div className="admin-menu" ref={adminMenuRef}>
@@ -614,7 +612,7 @@ export function App() {
</aside> </aside>
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}> <div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => reportAttentionCount ? void openCaseReport() : setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? reportAttentionCount ? 'SOURCE VERIFIED · REPORT REQUIRED' : 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div> <div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> <Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel {briefOpen && <BriefPanel
brief={caseState.brief} brief={caseState.brief}
@@ -630,7 +628,6 @@ export function App() {
onEditParty={setEditingPartyId} onEditParty={setEditingPartyId}
/>} />}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>} {storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{documents.length}</b></button>}
<div className="board-actions"> <div className="board-actions">
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button> <button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button> <button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
@@ -1107,7 +1104,7 @@ function Timeline({ items, range, selected, onSelect, onEdit }: { items: Tempora
const ticks = range 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: 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) } }) : 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 <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.exhibitId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer> return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span className="timeline-range">{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</span><button className="timeline-settings" type="button" aria-label="Timeline settings" title="Adjust timeline range" onClick={onEdit}><Settings2 size={15}/></button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.exhibitId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
} }
function localDateTime(value?: string) { function localDateTime(value?: string) {
+2 -4
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react' import type { ComponentType } from 'react'
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react' import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import type { CaseDocument, Connection, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, PartyExhibit, SourceFileType, TemporalFact } from './types' import type { CaseDocument, Connection, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, PartyExhibit, SourceFileType, TemporalFact } from './types'
export type WidgetCommand = export type WidgetCommand =
@@ -74,9 +74,7 @@ function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
function ClaimWidget({ exhibit }: ExhibitWidgetProps) { function ClaimWidget({ exhibit }: ExhibitWidgetProps) {
if (exhibit.type !== 'claim') return null if (exhibit.type !== 'claim') return null
return <div className="card-content claim-content"><div className="claim-heading"><BadgeCheck size={18}/><span>PROPOSITION TO PROVE</span></div> return <div className="card-content claim-content"><blockquote>{exhibit.statement}</blockquote></div>
<blockquote>{exhibit.statement}</blockquote><small>CONNECT SOURCE EVIDENCE WITH RED THREAD</small>
</div>
} }
function DocumentWidget({ exhibit }: ExhibitWidgetProps) { function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
+7 -9
View File
@@ -51,8 +51,6 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.case-heading { position: absolute; top: 20px; left: 27px; z-index: 2; display: flex; color: #dde2de; pointer-events: none; } .case-heading { position: absolute; top: 20px; left: 27px; z-index: 2; display: flex; color: #dde2de; pointer-events: none; }
.case-heading h1 { font: 500 24px Special Elite, serif; margin: 6px 0 5px; letter-spacing: .02em; } .case-heading h1 { font: 500 24px Special Elite, serif; margin: 6px 0 5px; letter-spacing: .02em; }
.case-heading p { margin: 0; color: #a06d3e; font: 9px IBM Plex Mono; letter-spacing: .13em; } .case-heading p { margin: 0; color: #a06d3e; font: 9px IBM Plex Mono; letter-spacing: .13em; }
.active-goal { pointer-events: auto; width: min(430px, 48vw); margin-top: 13px; padding: 8px 11px; display: grid; gap: 3px; text-align: left; border: 1px solid #536860; border-left: 3px solid #cf8644; background: #0b211ccc; color: #d5dcd8; box-shadow: 3px 4px #02090766; cursor: pointer; }
.active-goal span { color: #d18b49; font: 600 7px IBM Plex Mono; letter-spacing: .13em; }.active-goal b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 9px IBM Plex Mono; }.active-goal.complete { border-left-color: #71b889; }.active-goal.complete span { color: #7fc594; }
.case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; } .case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; }
.case-number b { color: #bdc9c3; font-size: 13px; } .case-number b { color: #bdc9c3; font-size: 13px; }
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; } .board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
@@ -144,8 +142,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.evidence-card.claim { min-height: 180px; padding: 15px 18px; background: linear-gradient(105deg,#e2dfcf,#d3d1c2); border: 2px solid #b99a68; box-shadow: 8px 10px 0 #020b0980,0 0 0 2px #526059; } .evidence-card.claim { min-height: 180px; padding: 15px 18px; background: linear-gradient(105deg,#e2dfcf,#d3d1c2); border: 2px solid #b99a68; box-shadow: 8px 10px 0 #020b0980,0 0 0 2px #526059; }
.evidence-card.claim::before { content:''; position:absolute; z-index:2; top:-7px; left:50%; width:13px; height:13px; translate:-50% 0; border-radius:50%; background:#9e392f; border:2px solid #5d1e1b; box-shadow:0 2px 2px #0006; } .evidence-card.claim::before { content:''; position:absolute; z-index:2; top:-7px; left:50%; width:13px; height:13px; translate:-50% 0; border-radius:50%; background:#9e392f; border:2px solid #5d1e1b; box-shadow:0 2px 2px #0006; }
.evidence-card.claim::after { background:#827d6e; } .evidence-card.claim::after { background:#827d6e; }
.claim-content { padding-top:12px; }.claim-heading { display:flex; align-items:center; gap:7px; color:#89502b; font:600 7px IBM Plex Mono; letter-spacing:.13em; } .claim-content { min-height:116px;padding:18px 5px 4px;display:grid;align-items:center; }
.claim-content blockquote { margin:14px 0 16px; padding:0; color:#202a26; font:22px/1.25 Special Elite; }.claim-content > small { color:#66716b; font:7px IBM Plex Mono; letter-spacing:.08em; } .claim-content blockquote { margin:0;padding:0;color:#202a26;font:22px/1.25 Special Elite; }
.folder-actions { position: absolute; right: 13px; bottom: 10px; } .folder-actions { position: absolute; right: 13px; bottom: 10px; }
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; } .folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
.folder-actions button:hover { background: #e5ca96; color: #321f0f; } .folder-actions button:hover { background: #e5ca96; color: #321f0f; }
@@ -255,8 +253,6 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-actions button:disabled { opacity: .35; cursor: default; } .board-actions button:disabled { opacity: .35; cursor: default; }
.board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; } .board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; }
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; } .board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
.open-files { position: absolute; top: 25px; left: 25px; z-index: 4; border: 1px solid #4c675f; background: #122e28; padding: 11px 13px; display: flex; gap: 8px; font: 9px IBM Plex Mono; cursor: pointer; }
.open-files b { color: #e19a4d; }
.story-strip { position: absolute; z-index: 4; right: 18px; top: 18px; width: 255px; max-height: 235px; overflow: auto; padding: 10px; background: #0d2721e8; border: 1px solid #466159; box-shadow: 5px 7px 18px #0008; } .story-strip { position: absolute; z-index: 4; right: 18px; top: 18px; width: 255px; max-height: 235px; overflow: auto; padding: 10px; background: #0d2721e8; border: 1px solid #466159; box-shadow: 5px 7px 18px #0008; }
.story-strip > small { display: block; padding: 2px 4px 8px; color: #b47a41; font: 600 8px IBM Plex Mono; letter-spacing: .12em; } .story-strip > small { display: block; padding: 2px 4px 8px; color: #b47a41; font: 600 8px IBM Plex Mono; letter-spacing: .12em; }
.story-strip button { width: 100%; display: grid; grid-template-columns: 64px 1fr; gap: 2px 7px; padding: 7px 5px; text-align: left; border: 0; border-top: 1px solid #304a43; background: transparent; cursor: pointer; } .story-strip button { width: 100%; display: grid; grid-template-columns: 64px 1fr; gap: 2px 7px; padding: 7px 5px; text-align: left; border: 0; border-top: 1px solid #304a43; background: transparent; cursor: pointer; }
@@ -288,8 +284,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.document-locator-beam .document-locator-pulse { fill: none; opacity: .65; animation: document-locator-pulse 1.1s ease-out infinite; } .document-locator-beam .document-locator-pulse { fill: none; opacity: .65; animation: document-locator-pulse 1.1s ease-out infinite; }
@keyframes document-locator-flow { to { stroke-dashoffset: -18; } } @keyframes document-locator-flow { to { stroke-dashoffset: -18; } }
@keyframes document-locator-pulse { from { r: 7; opacity: .8; } to { r: 24; opacity: 0; } } @keyframes document-locator-pulse { from { r: 7; opacity: .8; } to { r: 24; opacity: 0; } }
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; } .timeline-label { position:relative;border-right:1px solid #314a43;height:67px;padding-right:42px;display:flex;flex-direction:column;justify-content:center; }
.timeline-label b { font: 600 15px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 1px 0; overflow: hidden; color: #d29a61; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 500 11px IBM Plex Mono; } .timeline-label b { font:600 15px IBM Plex Mono;margin:4px 0; }.timeline-range { max-width:130px;overflow:hidden;color:#d29a61;text-overflow:ellipsis;white-space:nowrap;font:500 11px IBM Plex Mono; }.timeline-settings { position:absolute;right:10px;top:50%;width:30px;height:30px;padding:0;translate:0 -50%;display:grid;place-items:center;border:1px solid #49645c;background:#17352e;color:#d6a166;cursor:pointer;box-shadow:2px 3px 0 #020b0980; }.timeline-settings:hover,.timeline-settings:focus-visible { border-color:#c68a50;background:#24463d;color:#f0b978; }
.timeline-track { height: 75px; margin: 0 43px; position: relative; } .timeline-track { height: 75px; margin: 0 43px; position: relative; }
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; } .axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
.year { position: absolute; top: 53px; transform: translateX(-50%); color: #b7c7c1; font: 600 12px IBM Plex Mono; letter-spacing: .02em; text-shadow: 0 1px 1px #020907; } .year { position: absolute; top: 53px; transform: translateX(-50%); color: #b7c7c1; font: 600 12px IBM Plex Mono; letter-spacing: .02em; text-shadow: 0 1px 1px #020907; }
@@ -409,11 +405,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.documents-panel { width: 275px; } .documents-panel { width: 275px; }
.documents-panel.closed { margin-left: -275px; } .documents-panel.closed { margin-left: -275px; }
.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; } .timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }
.timeline-label { padding-right:34px; }
.timeline-range { max-width:76px;font-size:8px; }
.timeline-settings { right:6px;width:27px;height:27px; }
.timeline-key { display: none; } .timeline-key { display: none; }
.timeline-track { margin: 0 23px; } .timeline-track { margin: 0 23px; }
.document-window { width: 80vw; } .document-window { width: 80vw; }
.case-heading { left: 18px; } .case-heading { left: 18px; }
.active-goal { width: min(360px, calc(100vw - 92px)); }
.case-number { display: none; } .case-number { display: none; }
.brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; } .brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; }
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); } .brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }