Merge origin/main: reconcile briefing checklist and brief visuals

Both branches added a case-briefing checklist independently. Reconcile onto
the upstream {id,text} BriefChecklistItem model and keep the upstream brief /
luggage-tag visual refinements (boxed CHECKLIST section, assignment/heading
split), while retaining this branch's full-stack backing (checklist table,
board read/write/clone, importer/exporter) and the player-tickable interaction
(persisted locally per level). Keep the brief-goals list alongside the new
checklist. Align the server wire shape, importer, and exporter to {id,text}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:06:20 +02:00
co-authored by Claude Opus 4.8
11 changed files with 225 additions and 74 deletions
+73 -21
View File
@@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLeadIn, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
@@ -23,6 +23,7 @@ function screenshotFile(file: File, index: number) {
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
}
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function reportAcknowledgementKey(levelId: string) { return `gupi-osint-board:report-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) {
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
@@ -74,6 +75,8 @@ export function App() {
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [reportOpen, setReportOpen] = useState(false)
const [briefAcknowledged, setBriefAcknowledged] = useState(false)
const [reportAcknowledged, setReportAcknowledged] = useState(false)
const [playerName, setPlayerName] = useState('Player')
const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false)
@@ -89,6 +92,7 @@ export function App() {
const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const initiallyFocusedLevelRef = useRef<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const adminMenuRef = useRef<HTMLDivElement>(null)
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
@@ -99,6 +103,8 @@ export function App() {
if (!response.ok) throw new Error('Level unavailable')
const data = normalizeCase(await response.json())
setCaseState(data)
setBriefAcknowledged(Boolean(localStorage.getItem(briefAcknowledgementKey(data.id))))
setReportAcknowledged(Boolean(localStorage.getItem(reportAcknowledgementKey(data.id))))
const arrivals = data.newlyVisibleDocumentIds || []
if (arrivals.length) {
setArrivingExhibitIds(arrivals)
@@ -180,6 +186,41 @@ export function App() {
return () => window.clearTimeout(timer)
}, [arrivingExhibitIds])
useEffect(() => {
if (!caseState) return
if (briefOpen && !briefAcknowledged) {
localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
setBriefAcknowledged(true)
}
if (reportOpen && !reportAcknowledged) {
localStorage.setItem(reportAcknowledgementKey(caseState.id), new Date().toISOString())
setReportAcknowledged(true)
}
}, [briefOpen, reportOpen, briefAcknowledged, reportAcknowledged, caseState])
useLayoutEffect(() => {
if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return
const board = boardRef.current
const primaryClaim = caseState.exhibits.find(exhibit => exhibit.type === 'claim' && !exhibit.hidden)
if (!board || !primaryClaim) return
const bounds = board.getBoundingClientRect()
if (!bounds.width || !bounds.height) return
// In portrait the vertical tool rail occupies part of the apparent canvas.
// Centre the claim in the remaining usable board rather than underneath it.
const toolRail = board.parentElement?.querySelector<HTMLElement>('.board-actions')
const toolBounds = toolRail?.getBoundingClientRect()
const portraitToolInset = toolBounds && toolBounds.height > toolBounds.width * 2
? Math.max(0, bounds.right - toolBounds.left + 8)
: 0
const viewport = { ...caseState.viewport, zoom: clampBoardZoom(Math.max(caseState.viewport.zoom, .85)) }
initiallyFocusedLevelRef.current = caseState.id
setCaseState(current => current?.id === caseState.id ? {
...current,
viewport: viewportCenteredOnExhibit(viewport, primaryClaim, { width: bounds.width, height: bounds.height }, { right: portraitToolInset }),
} : current)
}, [caseState])
const update = useCallback((fn: (state: CaseState) => CaseState) => {
setCaseState(current => {
if (!current) return current
@@ -201,7 +242,10 @@ export function App() {
const ev = caseState.exhibits.find(e => e.id === id)
if (!ev) return
setSelected(id)
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
const bounds = boardRef.current?.getBoundingClientRect()
update(s => ({ ...s, viewport: bounds
? viewportCenteredOnExhibit(s.viewport, ev, { width: bounds.width, height: bounds.height })
: s.viewport }))
}
const extract = (doc: CaseDocument, regionId: string) => {
@@ -273,7 +317,10 @@ export function App() {
}
const closeBrief = () => {
if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
if (caseState) {
localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
setBriefAcknowledged(true)
}
setBriefOpen(false)
}
@@ -547,8 +594,8 @@ export function App() {
const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
const briefAttentionCount = !briefAcknowledged ? unresolvedConceptCount + pendingGoalCount : 0
const reportAttentionCount = !reportAcknowledged && caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
const widget=exhibitWidget(exhibit.type)
@@ -789,7 +836,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
const pinchDistance = useRef<number | null>(null)
const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
const [draggingWidget, setDraggingWidget] = useState(false)
const [trashActive, setTrashActive] = useState(false)
@@ -888,7 +934,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
setTrashActive(overTrash)
}
if (active.kind === 'thread-tag' && boardRef.current) {
if (active.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === active.id)
const from = connection ? pointForId(connection.fromExhibitId) : undefined
const to = connection ? pointForId(connection.toExhibitId) : undefined
@@ -896,7 +941,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
const bounds = boardRef.current.getBoundingClientRect()
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: 0 } : item) }))
}
} else if (active.kind === 'widget') update(s => { const next = moveBoardPoint({ x: active.originX, y: active.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === active.id ? { ...exhibit, ...next } : exhibit) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: active.originX, y: active.originY }, { x: dx, y: dy }) }))
@@ -954,7 +999,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
onPointerDown={e => {
const target = e.target as HTMLElement
if (!target.closest('.thread-tag')) setExpandedThreadTagId(null)
const emptyBoardDrag = e.button === 0 && !target.closest('.evidence-card, .source-file-widget, .thread-tag, button')
if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) }
}}
@@ -966,7 +1010,10 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
</svg>
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, 0); const compact = connection.tagStyle === 'compact'; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : 'Drag along thread · click to edit' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } onEditConnection(connection) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b></span>)}</button> })}
<svg className="thread-lead-ins" width={BOARD_W} height={BOARD_H} aria-hidden="true">
{state.connections.map(connection => { if (!connection.label || connection.tagStyle === 'compact') return null; const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <path key={`lead-in:${connection.id}`} d={threadTagLeadIn(p1, p2, connection.tightness, connection.tagPosition).path}/> })}
</svg>
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
{state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
</svg>
@@ -1199,17 +1246,15 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage')
const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50)
const save = (tag = label) => {
const lateralLimit = threadTagLateralLimit(tightness)
onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition,
tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) })
onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, tagOffset: 0 })
}
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
<p>What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
<label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Physical · hangs from the thread</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board to slide it along the red thread.</small></label>
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
<div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
</div>
@@ -1221,21 +1266,27 @@ function BriefPanel({ levelId, brief, goals, parties, recentlyCreatedExhibitId,
const partyById = new Map(parties.map(party => [party.id, party]))
// Player-side checklist ticks: guidance only, so persist locally per level rather than
// on the shared board.
const checklist = brief.checklist || []
const checkedKey = `gupi-osint-board:checklist:${levelId}`
const [checked, setChecked] = useState<Set<string>>(() => { try { return new Set<string>(JSON.parse(localStorage.getItem(checkedKey) || '[]')) } catch { return new Set() } })
const toggleChecked = (item: string) => setChecked(previous => { const next = new Set(previous); next.has(item) ? next.delete(item) : next.add(item); localStorage.setItem(checkedKey, JSON.stringify([...next])); return next })
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pending = goals.filter(goal => goal.status === 'pending').length
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
const authoredBody = brief.body.trim()
const [firstLine = '', ...remainingLines] = authoredBody.split(/\r?\n/)
const startsWithDisplayHeading = remainingLines.some(line => line.trim()) && firstLine.length < 70 && firstLine === firstLine.toLocaleUpperCase()
const assignment = (startsWithDisplayHeading ? remainingLines.join('\n').trim() : authoredBody)
|| goals.map(goal => [goal.title, goal.instructions].filter(Boolean).join('\n')).join('\n\n')
|| 'No brief has been authored yet.'
const checklist = brief.checklist || []
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
{checklist.length > 0 && <ul className="brief-checklist">{checklist.map((item, index) => { const done = checked.has(item); return <li key={index}><button type="button" className={done ? 'done' : ''} aria-pressed={done} onClick={() => toggleChecked(item)}><i>{done ? '☑' : '☐'}</i><span>{item}</span></button></li> })}</ul>}
<p>{assignment}</p>
{checklist.length > 0 && <section className="brief-checklist"><h3><ClipboardCheck size={16}/> CHECKLIST</h3><ol>{checklist.map(item => { const done = checked.has(item.id); return <li key={item.id}><button type="button" className={done ? 'done' : ''} aria-pressed={done} onClick={() => toggleChecked(item.id)}><i>{done ? '☑' : '☐'}</i><span>{item.text}</span></button></li> })}</ol></section>}
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
{brief.concepts.length > 0 && <><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>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
</aside>
}
@@ -1254,10 +1305,11 @@ function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; ha
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
const [body, setBody] = useState(brief.body)
const [checklist, setChecklist] = useState((brief.checklist || []).join('\n'))
const [checklist, setChecklist] = useState((brief.checklist || []).map(item => item.text).join('\n'))
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), checklist: checklist.split('\n').map(item => item.trim()).filter(Boolean), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...brief, body: body.trim(), checklist: checklist.split('\n').map(item => item.trim()).filter(Boolean).map(text => ({ id: uid('check'), text })), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={4} value={body} onChange={event => setBody(event.target.value)}/></label>