Add document locator beam

This commit is contained in:
2026-08-15 09:34:53 +02:00
parent 8f1f5a8743
commit 35685f765a
3 changed files with 74 additions and 4 deletions
+11
View File
@@ -47,6 +47,17 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
await expect(page.locator('.brief-panel')).not.toHaveClass(/\bminimized\b/) await expect(page.locator('.brief-panel')).not.toHaveClass(/\bminimized\b/)
await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click() await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click()
await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click()
const documentRow = page.locator('[data-document-row-id="22222222-2222-4222-8222-222222222222"]')
await documentRow.click()
await expect(documentRow).toHaveClass(/\bselected\b/)
await expect(page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')).toHaveClass(/\bdocument-located\b/)
await expect(page.locator('.document-locator-beam .document-locator-ray')).toBeVisible()
await documentRow.dblclick()
await expect(page.getByRole('button', { name: 'Close document', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Close document', exact: true }).click()
await page.getByRole('button', { name: 'Close documents', exact: true }).click()
page.once('dialog', dialog => dialog.accept('Disposable working theory')) page.once('dialog', dialog => dialog.accept('Disposable working theory'))
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click()) await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
const note = page.locator('.evidence-card.note').filter({ hasText: 'Disposable working theory' }) const note = page.locator('.evidence-card.note').filter({ hasText: 'Disposable working theory' })
+49 -3
View File
@@ -367,7 +367,7 @@ export function App() {
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label> <label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>} {canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
<div className="doc-list"> <div className="doc-list">
{filteredDocuments.map((doc, index) => <button className="doc-row" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}> {filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div> <div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/> <div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
</button>)} </button>)}
@@ -411,6 +411,7 @@ export function App() {
</div> </div>
</section> </section>
<DocumentLocatorBeam documentId={caseState.documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.evidence.map(item => `${item.id}:${item.x}:${item.y}:${String(item.config?.open)}`).join('|')}:${caseState.relations.map(item => `${item.id}:${String(item.config?.x)}:${String(item.config?.y)}`).join('|')}`} />
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> <TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> <Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} {openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
@@ -712,14 +713,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}> <svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })} {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
</svg> </svg>
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }} {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !folderIsOpen(ev) && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} 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 ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; 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) }} 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 (tool === 'move') onCardClick(ev.id) }}> onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header> <header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/> <Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
</article>})} </article>})}
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }} {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), located = open && selected === document.id, target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }}
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: 'relation', id: relation.id }) }} 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: 'relation', id: relation.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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header> <header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
@@ -731,6 +732,51 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
</div> </div>
} }
function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
useLayoutEffect(() => {
if (!documentId) { setBeam(null); return }
let frame = 0
let animateUntil = Date.now() + 500
const matchingElement = (attribute: 'documentRowId' | 'documentLocatorTarget') => Array.from(document.querySelectorAll<HTMLElement>(attribute === 'documentRowId' ? '[data-document-row-id]' : '[data-document-locator-target]')).find(element => element.dataset[attribute] === documentId)
const measure = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(function measureFrame() {
const source = matchingElement('documentRowId')
const target = matchingElement('documentLocatorTarget')
const viewport = document.querySelector<HTMLElement>('.board-viewport')
if (!source || !target || !viewport) { setBeam(null); return }
const from = source.getBoundingClientRect(), to = target.getBoundingClientRect(), bounds = viewport.getBoundingClientRect()
const x1 = Math.min(from.right - 3, bounds.left - 3)
const y1 = from.top + from.height / 2
const x2 = Math.max(bounds.left + 12, Math.min(bounds.right - 12, to.left + to.width / 2))
const y2 = Math.max(bounds.top + 12, Math.min(bounds.bottom - 12, to.top + to.height / 2))
const bend = Math.max(70, Math.abs(x2 - x1) * .32)
setBeam({ path: `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`, x: x2, y: y2 })
if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame)
})
}
measure()
const observer = new ResizeObserver(measure)
const observed = [matchingElement('documentRowId'), matchingElement('documentLocatorTarget'), document.querySelector<HTMLElement>('.board-viewport')].filter((element): element is HTMLElement => Boolean(element))
observed.forEach(element => observer.observe(element))
const handleLayoutChange = () => { animateUntil = Date.now() + 500; measure() }
window.addEventListener('resize', handleLayoutChange)
document.querySelector('.doc-list')?.addEventListener('scroll', handleLayoutChange, { passive: true })
return () => {
cancelAnimationFrame(frame)
observer.disconnect()
window.removeEventListener('resize', handleLayoutChange)
document.querySelector('.doc-list')?.removeEventListener('scroll', handleLayoutChange)
}
}, [documentId, layoutKey])
if (!beam) return null
return <svg className="document-locator-beam" aria-hidden="true">
<defs><filter id="document-locator-glow" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="4" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
<path className="document-locator-halo" d={beam.path}/><path className="document-locator-ray" d={beam.path}/><circle cx={beam.x} cy={beam.y} r="8"/><circle className="document-locator-pulse" cx={beam.x} cy={beam.y} r="15"/>
</svg>
}
function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) { function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) {
const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([]) const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([])
const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|') const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|')
+14 -1
View File
@@ -36,6 +36,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.no-document-results { min-height: 150px; padding: 32px 20px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: #617d75; }.no-document-results b { color: #91a59f; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }.no-document-results span { font: 8px/1.5 IBM Plex Mono; } .no-document-results { min-height: 150px; padding: 32px 20px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: #617d75; }.no-document-results b { color: #91a59f; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }.no-document-results span { font: 8px/1.5 IBM Plex Mono; }
.doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; } .doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; }
.doc-row:hover { background: #18342e; } .doc-row:hover { background: #18342e; }
.doc-row.selected { position: relative; background: #304534; box-shadow: inset 4px 0 #f1cf55, inset 0 0 20px #d8b94722; }
.doc-row.selected::after { content: 'LOCATED'; position: absolute; top: 7px; right: 9px; color: #f0ce57; font: 600 6px IBM Plex Mono; letter-spacing: .12em; }
.doc-row.selected .doc-icon { border-color: #e7c750; box-shadow: 0 0 12px #efd44f66, 3px 3px #081a16; }
.doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; } .doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; }
.doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; } .doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; }
.doc-row > svg { color: #527068; } .doc-row > svg { color: #527068; }
@@ -103,6 +106,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; } .evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; }
.evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); } .evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
.evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; } .evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
.evidence-card.document-located, .source-file-widget.document-located { z-index: 6; outline: 3px solid #f2d254; outline-offset: 6px; filter: drop-shadow(0 0 10px #f3d75d99) brightness(1.08); animation: document-locator-target 1.35s ease-in-out infinite alternate; }
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; } .evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; } .evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; } .evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
@@ -196,6 +201,14 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; } .file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; } .timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
.temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; } .temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
.document-locator-beam { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; }
.document-locator-beam path { fill: none; vector-effect: non-scaling-stroke; }
.document-locator-halo { stroke: #ffe276; stroke-width: 12; opacity: .13; filter: url(#document-locator-glow); }
.document-locator-ray { stroke: #ffe066; stroke-width: 2; stroke-dasharray: 10 8; opacity: .9; filter: url(#document-locator-glow); animation: document-locator-flow .7s linear infinite; }
.document-locator-beam circle { fill: #ffe36d; stroke: #fff3b0; stroke-width: 2; filter: url(#document-locator-glow); }
.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-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 { border-right: 1px solid #314a43; height: 67px; 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-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-track { height: 75px; margin: 0 43px; position: relative; } .timeline-track { height: 75px; margin: 0 43px; position: relative; }
@@ -271,4 +284,4 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; } .board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; }
.board-actions > b { padding: 2px 0; text-align: center; } .board-actions > b { padding: 2px 0; text-align: center; }
} }
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } } @media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }