From 52f0a31f446af45ca887b9611513b4c597a73ffd Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 19:49:44 +0200 Subject: [PATCH] Animate mugshot party identification --- e2e/board.smoke.spec.ts | 20 +++++++++++++++++++ src/App.tsx | 30 ++++++++++++++++++++++++++--- src/audio.ts | 38 +++++++++++++++++++++++++++++++++++++ src/exhibitRegistry.test.ts | 7 +++++-- src/exhibitRegistry.tsx | 21 +++++++++++++++++--- src/styles.css | 8 ++++++-- 6 files changed, 114 insertions(+), 10 deletions(-) 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; }