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
+1 -1
View File
@@ -40,7 +40,7 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
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('.brief-panel')).toBeVisible()
await expect(page.getByRole('button', { name: 'Brief', exact: true })).toContainText('2')
await expect(page.getByRole('button', { name: 'Brief', exact: true }).locator('.brief-count')).toHaveCount(0)
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 > p')).toBeHidden()
+1 -5
View File
@@ -153,9 +153,6 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
expect(tagAfter).not.toBeNull()
expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20)
await relationTag.click()
await expect(relationTag).toHaveClass(/\bexpanded\b/)
await expect(relationTag).toHaveAttribute('aria-expanded', 'true')
await relationTag.click()
await expect(page.getByText('Edit red thread')).toBeVisible()
expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50')
await page.locator('.tag-style-picker input[value="compact"]').check()
@@ -168,9 +165,8 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver')
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) }))
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: 0 }))
expect(eliasThread.tagPosition).not.toBe(50)
expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19)
expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2)
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
+2 -2
View File
@@ -18,8 +18,8 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
if (pageErrors.length) throw new Error(`Scene 7 failed to render: ${pageErrors.map(error => error.message).join('; ')}`)
await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible()
await expect(page.locator('.brief-panel')).toBeVisible()
await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor')
await expect(page.locator('.brief-goals section')).toHaveClass(/\bpending\b/)
await expect(page.locator('.brief-panel > p')).toContainText('Prove that Nils Aall Barricelli was an inventor')
await expect(page.locator('.brief-goals')).toHaveCount(0)
await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click()
const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST'
+1 -1
View File
@@ -76,7 +76,7 @@ function serializeLevel(board: TemplateBoardExport, assetFilenames: Map<string,
subtitle: state.subtitle || undefined,
timelineRange: timeline?.range,
brief: state.brief.body || state.brief.concepts.length || state.brief.checklist?.length
? prune({ body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })), checklist: state.brief.checklist })
? prune({ body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })), checklist: state.brief.checklist?.map(item => item.text) })
: undefined,
documents: documents.map(document => prune({
key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind,
+1 -1
View File
@@ -116,7 +116,7 @@ async function importLevel(baseUrl: string, folderDir: string, level: MysteryLev
}
const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()]))
if (level.brief) state.brief = { body: level.brief.body, concepts: (level.brief.concepts || []).map(concept => ({ id: randomUUID(), ...concept })), checklist: level.brief.checklist }
if (level.brief) state.brief = { body: level.brief.body, concepts: (level.brief.concepts || []).map(concept => ({ id: randomUUID(), ...concept })), checklist: (level.brief.checklist || []).map(text => ({ id: randomUUID(), text })) }
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view)
const folders = levelFolders.map(folder => ({
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
+5 -6
View File
@@ -419,7 +419,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
pool.query<{ text: string }>('SELECT text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [level.board_id]),
pool.query<{ id: string; text: string }>('SELECT id,text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [level.board_id]),
])
const blocks = new Map<string, string[]>()
@@ -481,7 +481,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
views, revision: Number(level.revision),
brief: { body: briefResult.rows[0]?.body || '', concepts, checklist: checklistResult.rows.map(row => row.text) }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
brief: { body: briefResult.rows[0]?.body || '', concepts, checklist: checklistResult.rows.map(row => ({ id: row.id, text: row.text })) }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined }
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
}
@@ -628,8 +628,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
const tagOffset = 0
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
}
@@ -659,8 +658,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
}
const brief = state.brief || { body: '', concepts: [] }
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
for (const [sortOrder, item] of (brief.checklist || []).map(text => text.trim()).filter(Boolean).entries())
await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), level.board_id, sortOrder, item])
for (const [sortOrder, text] of (brief.checklist || []).map(item => item.text.trim()).filter(Boolean).entries())
await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), level.board_id, sortOrder, text])
for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id')
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
+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>
+37 -5
View File
@@ -15,10 +15,12 @@ import {
relationPosition,
projectThreadTag,
threadCurve,
threadTagLeadIn,
threadTagLateralLimit,
threadTagPlacement,
timelinePositionPercent,
timelineRange,
viewportCenteredOnExhibit,
zoomFromWheel,
zoomFromPinch,
zoomViewportAt,
@@ -40,13 +42,25 @@ describe('red thread geometry', () => {
it('places and projects tags by percentage along the curve', () => {
const from = { x: 0, y: 0 }, to = { x: 200, y: 0 }
expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 })
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 })
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 })
})
it('reduces lateral tag travel as the thread becomes taut', () => {
expect(threadTagLateralLimit(0)).toBe(70)
expect(threadTagLateralLimit(100)).toBe(10)
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10)
it('constrains tags to the thread regardless of thread tension', () => {
expect(threadTagLateralLimit(0)).toBe(0)
expect(threadTagLateralLimit(100)).toBe(0)
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100)).toMatchObject({ x: 100, y: 0, lateralOffset: 0 })
expect(projectThreadTag({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, { x: 150, y: 80 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 })
})
it('draws a short foreground thread segment into the eyelet from the visual left', () => {
const leftToRight = threadTagLeadIn({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50)
expect(leftToRight.start.x).toBeLessThan(leftToRight.end.x)
expect(leftToRight.end).toEqual({ x: 100, y: 0 })
expect(leftToRight.path).toMatch(/^M .* C .* 100 0$/)
const rightToLeft = threadTagLeadIn({ x: 200, y: 0 }, { x: 0, y: 0 }, 100, 50)
expect(rightToLeft.start.x).toBeLessThan(rightToLeft.end.x)
expect(rightToLeft.end).toEqual({ x: 100, y: 0 })
})
})
@@ -113,6 +127,24 @@ describe('board coordinate math', () => {
expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
})
it('centres an exhibit in the usable mobile canvas', () => {
const viewport = viewportCenteredOnExhibit(
{ x: 0, y: 28, zoom: .85 },
{ x: 940, y: 360, width: 330, height: 190 },
{ width: 390, height: 658 },
{ right: 64 },
)
expect(viewport).toEqual({ x: -776.25, y: -57.75, zoom: .85 })
expect(viewport.x + (940 + 330 / 2) * viewport.zoom).toBe(163)
expect(viewport.y + (360 + 190 / 2) * viewport.zoom).toBe(329)
})
it('rejects invalid zoom when centring an exhibit', () => {
expect(() => viewportCenteredOnExhibit(
{ x: 0, y: 0, zoom: 0 }, folder, { width: 390, height: 658 },
)).toThrow(RangeError)
})
it('places new exhibits in deterministic open slots', () => {
const preferred = { x: 500, y: 400 }
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
+72 -15
View File
@@ -29,9 +29,37 @@ function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: Boar
}
}
export function threadTagLateralLimit(tightness = 65) {
const normalized = Math.max(0, Math.min(100, Number(tightness) || 0))
return Math.round(10 + (100 - normalized) * .6)
const interpolatePoint = (from: BoardPoint, to: BoardPoint, t: number): BoardPoint => ({
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
})
type Cubic = [BoardPoint, BoardPoint, BoardPoint, BoardPoint]
function splitCubic([from, c1, c2, to]: Cubic, t: number): [Cubic, Cubic] {
const a = interpolatePoint(from, c1, t)
const b = interpolatePoint(c1, c2, t)
const c = interpolatePoint(c2, to, t)
const d = interpolatePoint(a, b, t)
const e = interpolatePoint(b, c, t)
const point = interpolatePoint(d, e, t)
return [[from, a, d, point], [point, e, c, to]]
}
function cubicSegment(curve: Cubic, start: number, end: number): Cubic {
if (start > end) {
const [from, c1, c2, to] = cubicSegment(curve, end, start)
return [to, c2, c1, from]
}
const [, afterStart] = splitCubic(curve, start)
const relativeEnd = start >= 1 ? 1 : (end - start) / (1 - start)
return splitCubic(afterStart, relativeEnd)[0]
}
const pathCoordinate = (value: number) => Math.round(value * 1000) / 1000
export function threadTagLateralLimit(_tightness = 65) {
return 0
}
export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) {
@@ -39,12 +67,9 @@ export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness =
const position = Math.max(5, Math.min(95, Number(positionPercent) || 50))
const t = position / 100
const point = cubicPoint(from, c1, c2, to, t)
const tangent = cubicTangent(from, c1, c2, to, t)
const length = Math.hypot(tangent.x, tangent.y) || 1
const normal = { x: -tangent.y / length, y: tangent.x / length }
const maxLateralOffset = threadTagLateralLimit(tightness)
const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0))
return { x: point.x + normal.x * offset, y: point.y + normal.y * offset, positionPercent: position, lateralOffset: offset, maxLateralOffset }
return { x: point.x, y: point.y, positionPercent: position, lateralOffset: offset, maxLateralOffset }
}
export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) {
@@ -57,13 +82,8 @@ export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: nu
const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2
if (distance < bestDistance) { bestDistance = distance; bestT = t }
}
const point = cubicPoint(from, c1, c2, to, bestT)
const tangent = cubicTangent(from, c1, c2, to, bestT)
const length = Math.hypot(tangent.x, tangent.y) || 1
const normal = { x: -tangent.y / length, y: tangent.x / length }
const maxLateralOffset = threadTagLateralLimit(tightness)
const lateralOffset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, (pointer.x - point.x) * normal.x + (pointer.y - point.y) * normal.y))
return { positionPercent: Math.round(bestT * 100), lateralOffset: Math.round(lateralOffset), maxLateralOffset }
return { positionPercent: Math.round(bestT * 100), lateralOffset: 0, maxLateralOffset }
}
export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
@@ -72,6 +92,24 @@ export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint }
}
/** A short foreground segment that visibly enters a luggage-tag eyelet from the left. */
export function threadTagLeadIn(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50) {
const { c1, c2 } = threadControls(from, to, tightness)
const position = Math.max(5, Math.min(95, Number(positionPercent) || 50))
const tagT = position / 100
const tangent = cubicTangent(from, c1, c2, to, tagT)
const speed = Math.max(1, Math.hypot(tangent.x, tangent.y))
const leadLength = Math.max(.035, Math.min(.18, 42 / speed))
const entryT = Math.max(0, Math.min(1, tagT + (tangent.x < 0 ? leadLength : -leadLength)))
const [start, leadC1, leadC2] = cubicSegment([from, c1, c2, to], entryT, tagT)
const end = cubicPoint(from, c1, c2, to, tagT)
return {
path: `M ${pathCoordinate(start.x)} ${pathCoordinate(start.y)} C ${pathCoordinate(leadC1.x)} ${pathCoordinate(leadC1.y)}, ${pathCoordinate(leadC2.x)} ${pathCoordinate(leadC2.y)}, ${pathCoordinate(end.x)} ${pathCoordinate(end.y)}`,
start,
end,
}
}
export const MIN_BOARD_ZOOM = 0.45
export const MAX_BOARD_ZOOM = 1.5
@@ -126,6 +164,25 @@ export function nextOpenBoardPosition(
export interface BoardScreenSize { width: number; height: number }
export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number }
/** Centres an exhibit in the usable portion of the board viewport. */
export function viewportCenteredOnExhibit(
viewport: Viewport,
exhibit: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>,
screen: BoardScreenSize,
insets: BoardScreenInsets = {},
): Viewport {
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
const left = Math.max(0, insets.left || 0)
const top = Math.max(0, insets.top || 0)
const usableWidth = Math.max(1, screen.width - left - Math.max(0, insets.right || 0))
const usableHeight = Math.max(1, screen.height - top - Math.max(0, insets.bottom || 0))
return {
x: left + usableWidth / 2 - (exhibit.x + exhibit.width / 2) * viewport.zoom,
y: top + usableHeight / 2 - (exhibit.y + exhibit.height / 2) * viewport.zoom,
zoom: viewport.zoom,
}
}
/**
* Finds an open board position inside the portion of the canvas the player can
* currently see. Screen insets reserve space for board chrome such as the mobile
@@ -311,7 +368,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
if (Array.isArray(state.exhibits)) {
return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport,
relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] },
revision: Number(state.revision || 0), brief: { ...(state.brief || { body: '', concepts: [] }), checklist: state.brief?.checklist || [] },
goals: state.goals || [],
report: state.report,
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
@@ -359,7 +416,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], report: state.report, revision: Number(state.revision || 0),
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: { ...(state.brief || { body: '', concepts: [] }), checklist: state.brief?.checklist || [] }, goals: state.goals || [], report: state.report, revision: Number(state.revision || 0),
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
}
+29 -16
View File
@@ -80,19 +80,17 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.thread-tag.untagged { width: 13px; height: 13px; padding: 0; transform: translate(-50%, -50%); border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
.thread-tag.untagged i { display: none; }
.thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
.thread-tag.compact { transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; }
.thread-tag.compact { transform: translate(-4.5px, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; }
.thread-tag.compact i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
.thread-tag-compact-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; }
.thread-tag.compact:hover i, .thread-tag.compact:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); }
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, -13px); }
.thread-tag.labelled i { display: none; }
.thread-tag-content { display: grid; align-content: start; gap: 7px; height: 108px; padding-top: 10px; overflow: hidden; text-align: left; transition: transform .22s ease; }
.thread-tag-content small { padding-bottom: 4px; border-bottom: 1px solid #7e6542; color: #5b472d; font: 600 6px IBM Plex Mono; letter-spacing: .1em; }
.thread-tag-content b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; color: #33291c; font: 12px/1.25 Special Elite; }
.thread-tag-content em { color: #68472c; font: 600 6px IBM Plex Mono; letter-spacing: .06em; }
.thread-tag.labelled.expanded { z-index: 14; transform: translate(-50%, 4px) rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
.thread-tag.labelled.expanded .thread-tag-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
.thread-tag.labelled.expanded .thread-tag-content b { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
.thread-lead-ins { position: absolute; z-index: 16; inset: 0; overflow: visible; pointer-events: none; }
.thread-lead-ins path { fill: none; stroke: #8f2828; stroke-width: 3; stroke-linecap: round; filter: drop-shadow(1px 2px 0 #020907aa); }
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
@@ -238,10 +236,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.evidence-card.note.luggage-tag .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
.evidence-card.note.luggage-tag h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
.evidence-card.note.luggage-tag p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
.evidence-card.note.luggage-tag.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
.evidence-card.note.luggage-tag.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
.evidence-card.note.luggage-tag.selected h3 { font-size: 7px; }
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; filter: drop-shadow(7px 9px 5px #0007); }
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
@@ -262,11 +257,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.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 { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
.brief-panel > p { margin: 16px; padding: 13px; white-space: pre-line; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-checklist { margin: 0 16px 16px; padding: 0; list-style: none; display: grid; gap: 5px; }
.brief-checklist button { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; width: 100%; padding: 9px 11px; text-align: left; cursor: pointer; background: #d5d5ca; border: 1px solid #89928b; color: #293b35; font: 12px/1.4 Special Elite; }
.brief-checklist button:hover { background: #dcdccf; }
.brief-checklist button > i { font-style: normal; font-size: 15px; line-height: 1.1; color: #9a5f2e; }
.brief-checklist button.done { background: #cbd9cc; border-color: #78927d; }
.brief-checklist { margin: 0 16px 18px; padding: 14px 15px 15px; background: #d8d8cd; border: 1px solid #929991; }
.brief-checklist h3 { margin: 0 0 11px; display: flex; align-items: center; gap: 7px; color: #59442f; font: 600 9px IBM Plex Mono; letter-spacing: .1em; }
.brief-checklist ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; }
.brief-checklist button { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 9px; align-items: start; width: 100%; padding: 5px 6px; text-align: left; cursor: pointer; background: transparent; border: 0; color: #2c3834; font: 12px/1.45 Special Elite; }
.brief-checklist button:hover { background: #e4e3d6; }
.brief-checklist button > i { justify-self: center; font-style: normal; font-size: 16px; line-height: 1.25; color: #8a6747; }
.brief-checklist button.done > i { color: #437558; }
.brief-checklist button.done > span { color: #5b6862; text-decoration: line-through; }
.brief-goals { margin: 0 16px 16px; display: grid; gap: 7px; }.brief-goals section { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px; border: 1px solid #89928b; background: #d5d5ca; }.brief-goals section > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid #9b6232; border-radius: 50%; color: #854d25; font: 600 10px IBM Plex Mono; font-style: normal; }.brief-goals section > div { display: grid; gap: 4px; }.brief-goals b { color: #293b35; font: 600 9px IBM Plex Mono; }.brief-goals span { color: #5b6862; font: 10px/1.4 Special Elite; }.brief-goals em { color: #9a5f2e; font: 600 7px IBM Plex Mono; font-style: normal; letter-spacing: .08em; }.brief-goals section.complete { background: #cbd9cc; border-color: #78927d; }.brief-goals section.complete > i { border-color: #437558; background: #4d7f60; color: #f1f1e7; }.brief-goals section.complete em { color: #34644a; }
@@ -415,7 +411,24 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.document-window { width: 80vw; }
.case-heading { left: 18px; }
.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: 62px; padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(10px, env(safe-area-inset-right)); }
.brief-panel > header div { gap: 4px; }
.brief-panel > header small { font-size: 10px; line-height: 1.3; }
.brief-panel > header b { font-size: 15px; }
.brief-panel > header button { width: 38px; height: 36px; }
.brief-panel > p { margin: 18px 14px 22px; padding: 21px 18px; font-size: 22px; line-height: 1.55; }
.brief-checklist { margin: 0 14px 22px; padding: 18px 16px; }
.brief-checklist h3 { margin-bottom: 15px; font-size: 12px; }
.brief-checklist ol { gap: 13px; }
.brief-checklist li { min-height: 34px; padding: 4px 0 4px 48px; font-size: 17px; line-height: 1.5; }
.brief-checklist li::before { width: 34px; height: 34px; font-size: 12px; }
.brief-concepts section { padding: 18px 16px; }
.brief-concepts section > div:first-child { gap: 7px; }
.brief-concepts b { font-size: 15px; line-height: 1.35; }
.brief-concepts span { font-size: 16px; line-height: 1.55; }
.classify-actions, .resolved-actions { gap: 10px; margin-top: 14px; }
.classify-actions button, .resolved-actions button, .new-party-from-brief, .edit-brief { min-height: 46px; padding: 10px 12px; font-size: 12px; }
.dismiss-brief { min-height: 52px; font-size: 13px; }
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
+3 -1
View File
@@ -163,7 +163,9 @@ export interface BriefConcept {
resolvedPartyExhibitId?: string
}
export interface LevelBrief { body: string; concepts: BriefConcept[]; checklist?: string[] }
export interface BriefChecklistItem { id: string; text: string }
export interface LevelBrief { body: string; concepts: BriefConcept[]; checklist?: BriefChecklistItem[] }
export interface LevelGoal {
/** Present in author mode so the goal can be edited; omitted in play mode. */