Adding fixes to board, simplifications
This commit is contained in:
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.')
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -1104,7 +1101,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) {
|
||||||
|
|||||||
@@ -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
@@ -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)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user