feat: improve case brief classification flow
This commit is contained in:
+36
-14
@@ -11,6 +11,7 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
||||
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
||||
|
||||
function uid(_prefix: string) { return crypto.randomUUID() }
|
||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||
function connectionPoint(item: Evidence) {
|
||||
return exhibitWidget(item.type).connectionPoint(item)
|
||||
}
|
||||
@@ -37,6 +38,7 @@ export function App() {
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [editingBrief, setEditingBrief] = useState(false)
|
||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -53,8 +55,10 @@ export function App() {
|
||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
|
||||
if (!response.ok) throw new Error('Level unavailable')
|
||||
const data = await response.json()
|
||||
setCaseState(normalizeCase(data)); setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
const data = normalizeCase(await response.json())
|
||||
setCaseState(data)
|
||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
})
|
||||
.catch(() => {
|
||||
const cached = localStorage.getItem('gupi-osint-board:last')
|
||||
@@ -66,6 +70,12 @@ export function App() {
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentlyCreatedExhibitId) return
|
||||
const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [recentlyCreatedExhibitId])
|
||||
|
||||
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
||||
setCaseState(current => {
|
||||
if (!current) return current
|
||||
@@ -101,7 +111,7 @@ export function App() {
|
||||
x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260,
|
||||
}
|
||||
update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] }))
|
||||
setOpenDoc(null); setSelected(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
}
|
||||
|
||||
const addNote = () => {
|
||||
@@ -110,7 +120,7 @@ export function App() {
|
||||
const { viewport } = caseState
|
||||
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
|
||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
|
||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
||||
}
|
||||
|
||||
const addEvent = () => {
|
||||
@@ -120,7 +130,7 @@ export function App() {
|
||||
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
||||
eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 }
|
||||
update(state => ({ ...state, evidence: [...state.evidence, event] }))
|
||||
setSelected(event.id); setEditingEventId(event.id)
|
||||
setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id)
|
||||
}
|
||||
|
||||
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
|
||||
@@ -141,7 +151,14 @@ export function App() {
|
||||
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
|
||||
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
|
||||
}))
|
||||
setSelected(partyId); setBriefOpen(false); setEditingPartyId(partyId)
|
||||
setSelected(partyId)
|
||||
setRecentlyCreatedExhibitId(partyId)
|
||||
setStatus(`${partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
|
||||
}
|
||||
|
||||
const closeBrief = () => {
|
||||
if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
|
||||
setBriefOpen(false)
|
||||
}
|
||||
|
||||
const handleCardClick = (id: string) => {
|
||||
@@ -219,6 +236,7 @@ export function App() {
|
||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||
|
||||
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
|
||||
const temporalItems: TemporalItem[] = [
|
||||
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
|
||||
@@ -235,7 +253,7 @@ export function App() {
|
||||
<header className="menubar">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
<nav>
|
||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
</nav>
|
||||
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
||||
</header>
|
||||
@@ -256,15 +274,17 @@ export function App() {
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { 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) 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></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
parties={caseState.evidence.filter(item => item.type === 'party')}
|
||||
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
||||
canEdit={requestedEditMode && Boolean(caseState.editingAllowed)}
|
||||
onClose={() => setBriefOpen(false)}
|
||||
onClose={closeBrief}
|
||||
onEdit={() => setEditingBrief(true)}
|
||||
onClassify={classifyConcept}
|
||||
onLocate={focusEvidence}
|
||||
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)}</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>{caseState.documents.length}</b></button>}
|
||||
@@ -348,7 +368,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||
}
|
||||
|
||||
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const suppressClick = useRef(false)
|
||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||
@@ -454,7 +474,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
||||
<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}/> })}
|
||||
</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} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''}`} 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 definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${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 (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
|
||||
@@ -539,12 +559,14 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function BriefPanel({ brief, parties, canEdit, onClose, onEdit, onClassify, onLocate }: { brief: LevelBrief; parties: Evidence[]; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void }) {
|
||||
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
||||
return <aside className="brief-panel"><header><div><small>LEVEL BRIEF</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
|
||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
return <aside className="brief-panel"><header><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
|
||||
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
||||
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={resolved ? 'resolved' : ''} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <button className="resolved-party" onClick={() => onLocate(resolved.id)}>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()} · LOCATE</button> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
|
||||
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||||
</aside>
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -12,7 +12,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #d88938; color: #e99a44; font: 600 11px IBM Plex Mono; transform: rotate(-3deg); }
|
||||
.menubar nav { display: flex; height: 100%; align-items: stretch; }
|
||||
.menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; }
|
||||
.menubar nav button:hover { background: #112f2a; color: white; }
|
||||
.menubar nav button:hover, .menubar nav button.active { background: #112f2a; color: white; }
|
||||
.brief-count { display: inline-grid; min-width: 17px; height: 17px; margin-left: 7px; padding: 0 4px; place-items: center; border-radius: 9px; background: #b56d30; color: #fff3df; font: 600 9px IBM Plex Mono; }
|
||||
.terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||||
.terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }
|
||||
.terminal-status span { color: #d9a05d; margin-left: 20px; font-size: 13px; }
|
||||
@@ -63,6 +64,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.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.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
||||
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
||||
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
||||
.evidence-card p { font: 500 16px Special Elite, serif; line-height: 1.35; margin: 0 0 12px; }
|
||||
@@ -136,8 +139,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.brief-panel > header { height: 44px; padding: 0 8px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; }
|
||||
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { margin-left: auto; width: 25px; height: 24px; display: grid; place-items: center; }
|
||||
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
|
||||
.classify-actions { display: flex; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-party, .edit-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-party { margin-top: 8px; background: #bfcfbe; }.edit-brief { margin: 12px 15px; background: #234c41; color: white; }
|
||||
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section.just-resolved { animation: concept-resolved 1.15s ease-out; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
|
||||
.classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-actions > span { margin-right: auto; display: inline-flex; align-items: center; gap: 5px; color: #31584d; font: 600 8px IBM Plex Mono; }.edit-brief { margin: 12px 15px; background: #234c41; color: white; }.dismiss-brief { width: calc(100% - 30px); margin: 0 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }
|
||||
@keyframes concept-resolved { 0% { background: #e8b76c; box-shadow: inset 4px 0 #b56127; } 100% { background: #d5ddcf; box-shadow: inset 0 0 transparent; } }
|
||||
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
|
||||
.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; }
|
||||
@@ -208,3 +212,4 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved { animation: none; }.board, .documents-panel { transition: none; } }
|
||||
|
||||
Reference in New Issue
Block a user