Refine brief and luggage tag visuals
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
+38
-8
@@ -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, 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 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)
|
||||
@@ -100,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)
|
||||
@@ -181,6 +186,18 @@ 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
|
||||
@@ -300,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)
|
||||
}
|
||||
|
||||
@@ -574,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)
|
||||
@@ -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>}
|
||||
</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> })}
|
||||
<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>
|
||||
@@ -1243,13 +1266,20 @@ function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit,
|
||||
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>
|
||||
{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>}
|
||||
<p>{assignment}</p>
|
||||
{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>
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -1270,7 +1300,7 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
|
||||
const [body, setBody] = useState(brief.body)
|
||||
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(), 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>
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
relationPosition,
|
||||
projectThreadTag,
|
||||
threadCurve,
|
||||
threadTagLeadIn,
|
||||
threadTagLateralLimit,
|
||||
threadTagPlacement,
|
||||
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(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 = {
|
||||
|
||||
+57
-2
@@ -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) {
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -313,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)],
|
||||
@@ -361,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 || [] }
|
||||
}
|
||||
|
||||
+25
-2
@@ -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 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-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; }
|
||||
@@ -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.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-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; }
|
||||
.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; } }
|
||||
@@ -401,7 +407,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
@@ -163,7 +163,9 @@ export interface BriefConcept {
|
||||
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 {
|
||||
/** Present in author mode so the goal can be edited; omitted in play mode. */
|
||||
|
||||
Reference in New Issue
Block a user