diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts
index 250031e..802566b 100644
--- a/e2e/board.smoke.spec.ts
+++ b/e2e/board.smoke.spec.ts
@@ -190,6 +190,26 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
await expect(page.locator('.evidence-card.party')).toHaveCount(3)
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau', 'Mara Elise Voss'])
+ await file.dblclick()
+ await page.getByRole('button',{ name:'TYPE',exact:true }).click()
+ await waitForSave(page,() => page.getByRole('menuitemradio',{ name:/Mugshot/ }).click())
+ await expect(file.locator('.mugshot-caption')).toHaveText('')
+ await page.getByRole('button',{ name:'Close document',exact:true }).click()
+ const adaParty=page.locator('.evidence-card.party').filter({ hasText:'Ada Lovelace' })
+ await adaParty.click()
+ await page.getByRole('button',{ name:'Red thread' }).click()
+ await file.click()
+ await expect(page.getByLabel('Thread tag')).toHaveValue('Identified as…')
+ await waitForSave(page,() => page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click())
+ await expect(file).toHaveAttribute('data-identified-party-id',/\S+/)
+ await expect(file.locator('.mugshot-caption')).toContainText('Ada Lovelace')
+ await page.reload()
+ await expect(file.locator('.mugshot-caption')).toContainText('Ada Lovelace')
+ await adaParty.getByRole('button',{ name:'EDIT DOSSIER',exact:true }).click()
+ await page.getByLabel('Party name').fill('Ada Byron Lovelace')
+ await waitForSave(page,() => page.getByRole('button',{ name:'SAVE DOSSIER',exact:true }).click())
+ await expect(file.locator('.mugshot-caption')).toContainText('Ada Byron Lovelace')
+
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
await page.getByLabel('Event title').fill('The browser clue was connected')
diff --git a/src/App.tsx b/src/App.tsx
index a568c0c..0c76060 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, 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, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
-import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, 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'
const BOARD_W = 2400
@@ -946,18 +947,41 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
{definition.heading(ev, widgetContext)}{String(i + 1).padStart(3, '0')}
})}
- {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}
onUpdateDocumentCue(document.id, cue)}/>
- {document.title}
+ {document.captureKind === 'photo' ? : {document.title}}
})}
}
+function MugshotCaption({ name }:{ name:string }) {
+ const [visibleName,setVisibleName]=useState(name)
+ const [writing,setWriting]=useState(false)
+ const previousName=useRef(name)
+ useEffect(() => {
+ if (name === previousName.current) return
+ previousName.current=name
+ if (!name) { setVisibleName('');setWriting(false);return }
+ const letterDelay=Math.max(48,Math.min(82,900 / name.length))
+ const duration=Math.round(letterDelay * name.length)
+ let character=0
+ setVisibleName('');setWriting(true)
+ const stopSound=audio.sharpie(duration + 80)
+ const timer=window.setInterval(() => {
+ character+=1
+ setVisibleName(name.slice(0,character))
+ if (character >= name.length) { window.clearInterval(timer);setWriting(false) }
+ },letterDelay)
+ return () => { window.clearInterval(timer);stopSound?.() }
+ },[name])
+ return {visibleName}
+}
+
function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
useLayoutEffect(() => {
diff --git a/src/audio.ts b/src/audio.ts
index 2663f1a..b676616 100644
--- a/src/audio.ts
+++ b/src/audio.ts
@@ -21,6 +21,22 @@ function noise(context: AudioContext) {
return noiseBuffer
}
+let markerNoiseBuffer:AudioBuffer | null=null
+function markerNoise(context:AudioContext) {
+ if (!markerNoiseBuffer) {
+ const length=Math.floor(context.sampleRate * .31)
+ markerNoiseBuffer=context.createBuffer(1,length,context.sampleRate)
+ const data=markerNoiseBuffer.getChannelData(0)
+ let previous=0
+ for (let index=0;index < length;index++) {
+ const white=Math.random() * 2 - 1
+ previous=previous * .66 + white * .34
+ data[index]=previous * (.72 + Math.sin(index / 37) * .18)
+ }
+ }
+ return markerNoiseBuffer
+}
+
const music = typeof Audio !== 'undefined' ? new Audio() : null
if (music) music.loop = true
let currentUrl: string | null = null
@@ -100,6 +116,28 @@ export const audio = {
src.connect(filter).connect(gain).connect(context.destination)
src.start(now); src.stop(now + 0.04)
},
+ // A restrained felt-tip-on-paper scratch. The caller supplies the handwriting
+ // duration so the sound ends with the incremental letter reveal.
+ sharpie(durationMs:number) {
+ if (muted) return undefined
+ const context=audioContext()
+ if (!context) return undefined
+ if (context.state === 'suspended') void context.resume()
+ const duration=Math.max(.12,Math.min(3,durationMs / 1000))
+ const source=context.createBufferSource();source.buffer=markerNoise(context);source.loop=true
+ const filter=context.createBiquadFilter();filter.type='bandpass';filter.frequency.value=1180;filter.Q.value=.62
+ const gain=context.createGain()
+ const now=context.currentTime + .012,end=now + duration
+ gain.gain.setValueAtTime(.0001,now)
+ gain.gain.linearRampToValueAtTime(.032,now + .035)
+ for (let at=now + .055;at < end - .04;at += .045) gain.gain.setValueAtTime(.018 + Math.random() * .026,at)
+ gain.gain.exponentialRampToValueAtTime(.0001,end)
+ source.connect(filter).connect(gain).connect(context.destination)
+ let ended=false
+ source.onended=() => { ended=true }
+ source.start(now);source.stop(end + .02)
+ return () => { if (!ended) { try { source.stop() } catch { /* already stopped */ } } }
+ },
// Resume the context and retry pending music on a user gesture.
resume() {
void audioContext()?.resume?.()
diff --git a/src/exhibitRegistry.test.ts b/src/exhibitRegistry.test.ts
index de7ed94..5283579 100644
--- a/src/exhibitRegistry.test.ts
+++ b/src/exhibitRegistry.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { DocumentCaptureKind, DocumentExhibit, SourceFileType } from './types'
-import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
+import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry, mugshotIdentification } from './exhibitRegistry'
describe('frontend exhibit registry', () => {
it('registers every normalized exhibit type', () => {
@@ -32,7 +32,10 @@ describe('frontend exhibit registry', () => {
const claim={ id:'claim',type:'claim' as const,title:'Inventor',statement:'Nils was an inventor.',...placed }
const photo:DocumentExhibit={ id:'photo',type:'document',title:'Portrait',body:[],regions:[],fileType:'image',captureKind:'photo',metadata:{},...placed }
expect(defaultConnectionLabel(party,claim)).toBe('Subject of claim')
- expect(defaultConnectionLabel(photo,party)).toBe('Depicts…')
+ expect(defaultConnectionLabel(photo,party)).toBe('Identified as…')
expect(defaultConnectionLabel(photo,claim)).toBe('Proof that…')
+ const connection={ id:'identity',fromExhibitId:party.id,toExhibitId:photo.id,label:'Identified as…',tightness:65,tagStyle:'luggage' as const,tagPosition:50,tagOffset:0 }
+ expect(mugshotIdentification(photo,[party,claim,photo],[connection])).toEqual({ party,connection })
+ expect(mugshotIdentification({ ...photo,captureKind:'scene' },[party,photo],[connection])).toBeNull()
})
})
diff --git a/src/exhibitRegistry.tsx b/src/exhibitRegistry.tsx
index 496edfd..ee6910c 100644
--- a/src/exhibitRegistry.tsx
+++ b/src/exhibitRegistry.tsx
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react'
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
-import type { CaseDocument, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
+import type { CaseDocument, Connection, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, PartyExhibit, SourceFileType, TemporalFact } from './types'
export type WidgetCommand =
| { type: 'open-document'; documentId: string }
@@ -142,20 +142,35 @@ export type DocumentCaptureDefinition = {
}
export const documentCaptureRegistry:Record = {
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
- photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:240} },
+ photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} },
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
}
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
+export type MugshotIdentification = { party:PartyExhibit;connection:Connection }
+
+/** A Mugshot caption is a projection of its latest Party connection, never copied document metadata. */
+export function mugshotIdentification(document:DocumentExhibit,exhibits:Exhibit[],connections:Connection[]):MugshotIdentification | null {
+ if (document.captureKind !== 'photo') return null
+ const byId=new Map(exhibits.map(exhibit => [exhibit.id,exhibit]))
+ for (let index=connections.length - 1;index >= 0;index--) {
+ const connection=connections[index]
+ const otherId=connection.fromExhibitId === document.id ? connection.toExhibitId : connection.toExhibitId === document.id ? connection.fromExhibitId : null
+ const other=otherId ? byId.get(otherId) : null
+ if (other?.type === 'party') return { party:other,connection }
+ }
+ return null
+}
+
/** Context-sensitive starter copy. A Party is never treated as proof by default. */
export function defaultConnectionLabel(first:Exhibit,second:Exhibit) {
const types = new Set([first.type,second.type])
if (types.has('party') && types.has('claim')) return 'Subject of claim'
const document = first.type === 'document' ? first : second.type === 'document' ? second : null
if (document && types.has('party')) {
- if (document.captureKind === 'photo') return 'Depicts…'
+ if (document.captureKind === 'photo') return 'Identified as…'
if (document.captureKind === 'scene') return 'Shows…'
return 'Concerns…'
}
diff --git a/src/styles.css b/src/styles.css
index 2c1ba15..54ebfc5 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -205,11 +205,15 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.source-file-widget.capture-kind-clipping:hover .source-file-actions,
.source-file-widget.capture-kind-full_page:hover .source-file-actions,
.source-file-widget.selected .source-file-actions { opacity: 1; }
-.source-file-widget.capture-kind-photo { padding: 10px 10px 12px; transform-origin: 50% 15%; }
+.source-file-widget.capture-kind-photo { min-height: 250px; padding: 10px 10px 12px; transform-origin: 50% 15%; }
.source-file-widget.capture-kind-photo.open { transform: scale(1) rotate(-1.25deg); }
.source-file-widget.capture-kind-photo .source-file-preview { height: 151px; }
.source-file-widget.capture-kind-photo .source-file-preview img { object-fit: cover; filter: saturate(.82) contrast(1.04) sepia(.08); }
.source-file-widget.capture-kind-photo > strong { padding: 1px 4px 0; text-align: center; font: 15px/1.1 "Marker Felt", "Comic Sans MS", cursive; transform: rotate(-.5deg); }
+.source-file-widget.capture-kind-photo > strong.mugshot-caption { min-height: 29px; display: grid; align-content: center; }
+.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
+.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
+@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; }
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
@@ -418,7 +422,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
}
-@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse, .goal-complete-shade, .goal-complete-card { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
+@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse, .goal-complete-shade, .goal-complete-card, .mugshot-caption.writing span::after { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
/* Narrative layer: splash + NPC dialogue */
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }