Refine brief and luggage tag visuals

This commit is contained in:
2026-08-23 22:47:28 +02:00
parent f91e08b515
commit 55ab07fcd8
7 changed files with 138 additions and 16 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) expect(await page.locator('.timeline-range').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11)
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/) await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
await expect(page.locator('.brief-panel')).toBeVisible() await expect(page.locator('.brief-panel')).toBeVisible()
await expect(page.getByRole('button', { name: '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 page.getByRole('button', { name: 'Minimize brief', exact: true }).click()
await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/) await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/)
await expect(page.locator('.brief-panel > p')).toBeHidden() await expect(page.locator('.brief-panel > p')).toBeHidden()
+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('; ')}`) 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.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible()
await expect(page.locator('.brief-panel')).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-panel > p')).toContainText('Prove that Nils Aall Barricelli was an inventor')
await expect(page.locator('.brief-goals section')).toHaveClass(/\bpending\b/) await expect(page.locator('.brief-goals')).toHaveCount(0)
await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click() await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click()
const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST' const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST'
+38 -8
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 type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin' import { AdminPanel } from './admin'
import { audio } from './audio' import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, 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 { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative' 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() }) 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 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) { function documentSearchText(document: CaseDocument) {
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType, 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]), ...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 [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
const [briefOpen, setBriefOpen] = useState(false) const [briefOpen, setBriefOpen] = useState(false)
const [reportOpen, setReportOpen] = useState(false) const [reportOpen, setReportOpen] = useState(false)
const [briefAcknowledged, setBriefAcknowledged] = useState(false)
const [reportAcknowledged, setReportAcknowledged] = useState(false)
const [playerName, setPlayerName] = useState('Player') const [playerName, setPlayerName] = useState('Player')
const [editingBrief, setEditingBrief] = useState(false) const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false)
@@ -100,6 +103,8 @@ export function App() {
if (!response.ok) throw new Error('Level unavailable') if (!response.ok) throw new Error('Level unavailable')
const data = normalizeCase(await response.json()) const data = normalizeCase(await response.json())
setCaseState(data) setCaseState(data)
setBriefAcknowledged(Boolean(localStorage.getItem(briefAcknowledgementKey(data.id))))
setReportAcknowledged(Boolean(localStorage.getItem(reportAcknowledgementKey(data.id))))
const arrivals = data.newlyVisibleDocumentIds || [] const arrivals = data.newlyVisibleDocumentIds || []
if (arrivals.length) { if (arrivals.length) {
setArrivingExhibitIds(arrivals) setArrivingExhibitIds(arrivals)
@@ -181,6 +186,18 @@ export function App() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [arrivingExhibitIds]) }, [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(() => { useLayoutEffect(() => {
if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return
const board = boardRef.current const board = boardRef.current
@@ -300,7 +317,10 @@ export function App() {
} }
const closeBrief = () => { 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) setBriefOpen(false)
} }
@@ -574,8 +594,8 @@ export function App() {
const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount const briefAttentionCount = !briefAcknowledged ? unresolvedConceptCount + pendingGoalCount : 0
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0 const reportAttentionCount = !reportAcknowledged && caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed) const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => { const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
const widget=exhibitWidget(exhibit.type) const widget=exhibitWidget(exhibit.type)
@@ -990,6 +1010,9 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
{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>} {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> </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, 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> })} {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}> <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 })} {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> </svg>
@@ -1243,13 +1266,20 @@ function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit,
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pending = goals.filter(goal => goal.status === 'pending').length const pending = goals.filter(goal => goal.status === 'pending').length
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION' 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> 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> <p>{assignment}</p>
{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>} {checklist.length > 0 && <section className="brief-checklist"><h3><ClipboardCheck size={16}/> CHECKLIST</h3><ol>{checklist.map(item => <li key={item.id}>{item.text}</li>)}</ol></section>}
{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> {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></>} <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>} {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> </aside>
} }
@@ -1270,7 +1300,7 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
const [body, setBody] = useState(brief.body) const [body, setBody] = useState(brief.body)
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts) const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }]) 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(), 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(), 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> <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> <div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label> <label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
+12
View File
@@ -15,6 +15,7 @@ import {
relationPosition, relationPosition,
projectThreadTag, projectThreadTag,
threadCurve, threadCurve,
threadTagLeadIn,
threadTagLateralLimit, threadTagLateralLimit,
threadTagPlacement, threadTagPlacement,
timelinePositionPercent, timelinePositionPercent,
@@ -50,6 +51,17 @@ describe('red thread geometry', () => {
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100)).toMatchObject({ x: 100, y: 0, lateralOffset: 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 }) 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 })
})
}) })
const folder: FolderExhibit = { const folder: FolderExhibit = {
+57 -2
View File
@@ -21,6 +21,43 @@ function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardP
} }
} }
function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
const inverse = 1 - t
return {
x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x),
y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y),
}
}
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) { export function threadTagLateralLimit(_tightness = 65) {
return 0 return 0
} }
@@ -55,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 } 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 MIN_BOARD_ZOOM = 0.45
export const MAX_BOARD_ZOOM = 1.5 export const MAX_BOARD_ZOOM = 1.5
@@ -313,7 +368,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
if (Array.isArray(state.exhibits)) { if (Array.isArray(state.exhibits)) {
return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport, 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[], 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 || [], goals: state.goals || [],
report: state.report, report: state.report,
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)], views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
@@ -361,7 +416,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id), 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)) 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, 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, updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] } newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
} }
+25 -2
View File
@@ -89,6 +89,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.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 { 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 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 b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; color: #33291c; font: 12px/1.25 Special Elite; }
.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 { 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); } .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; } .party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
@@ -255,7 +257,11 @@ 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 > 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.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-panel > p { margin: 16px; padding: 13px; white-space: pre-line; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.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; } .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; counter-reset: brief-step; display: grid; gap: 9px; }
.brief-checklist li { counter-increment: brief-step; position: relative; min-height: 25px; padding: 4px 0 3px 37px; color: #2c3834; font: 12px/1.45 Special Elite; }
.brief-checklist li::before { content: counter(brief-step); position: absolute; left: 0; top: 0; width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid #8a6747; background: #e5e0d2; color: #704a2e; font: 600 9px IBM Plex Mono; }
.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; } .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, .new-party-from-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, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; } .classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief, .new-party-from-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, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 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; } } @keyframes concept-resolved { 0% { background: #e8b76c; box-shadow: inset 4px 0 #b56127; } 100% { background: #d5ddcf; box-shadow: inset 0 0 transparent; } }
@@ -401,7 +407,24 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.document-window { width: 80vw; } .document-window { width: 80vw; }
.case-heading { left: 18px; } .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 { 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; } .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; } .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; } .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 resolvedPartyExhibitId?: string
} }
export interface LevelBrief { body: string; concepts: BriefConcept[] } export interface BriefChecklistItem { id: string; text: string }
export interface LevelBrief { body: string; concepts: BriefConcept[]; checklist?: BriefChecklistItem[] }
export interface LevelGoal { export interface LevelGoal {
/** Present in author mode so the goal can be edited; omitted in play mode. */ /** Present in author mode so the goal can be edited; omitted in play mode. */