From ffa81284df8b64f53b40b68697b32fcc0c5d6c4a Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 18:30:31 +0200 Subject: [PATCH] feat: add handwritten cues to text exhibits --- e2e/mystery.acceptance.spec.ts | 5 +++++ src/App.tsx | 6 +++--- src/exhibitRegistry.tsx | 12 ++++++++++-- src/styles.css | 6 ++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index ecaa9b2..e777603 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -92,6 +92,10 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await waitForSave(page, () => page.getByRole('button', { name: 'Zoom out', exact: true }).click()) await waitForSave(page, () => movement.getByRole('button', { name: 'OPEN', exact: true }).click()) const manifest = page.locator('.source-file-widget.open').filter({ hasText: 'Carrier Dispatch Manifest' }) + await expect(manifest.locator('.text-source-excerpt')).toContainText('HARBOR & FELL LOGISTICS') + await expect(manifest.locator('.source-file-preview svg')).toHaveCount(0) + const manifestCue = page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true }) + await waitForSave(page, () => manifestCue.fill('ELIAS · H&F 14')) await elias.click() await page.getByRole('button', { name: 'Red thread', exact: true }).click() const boardBox = await page.locator('.board-viewport').boundingBox() @@ -135,6 +139,7 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await expect(page.locator('.evidence-card.party')).toHaveCount(6) await expect(page.locator('.evidence-card.event')).toHaveCount(3) await expect(page.locator('.evidence-card.note')).toHaveCount(1) + await expect(page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })).toHaveValue('ELIAS · H&F 14') await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3') await expect(page.locator('.event-support-lines line')).toHaveCount(6) await expect(page.locator('.connections path')).toHaveCount(3) diff --git a/src/App.tsx b/src/App.tsx index ec6ab6e..d0b6aa6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -331,7 +331,7 @@ export function App() {
{ if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
- setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> + setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> {briefOpen && item.type === 'party')} @@ -452,7 +452,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} -function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { +function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) @@ -622,7 +622,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr onPointerDown={event => { 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: 'relation', id: relation.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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
-
+
onUpdateDocumentCue(document.id, cue)}/>
{document.title}
})} diff --git a/src/exhibitRegistry.tsx b/src/exhibitRegistry.tsx index f9cab0d..c291df4 100644 --- a/src/exhibitRegistry.tsx +++ b/src/exhibitRegistry.tsx @@ -69,7 +69,7 @@ export function exhibitWidget(type: EvidenceType) { return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note } -type DocumentWidgetProps = { document: CaseDocument; source: string } +type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void } export type DocumentWidgetDefinition = { label: string Preview: ComponentType @@ -82,6 +82,14 @@ function ImagePreview({ document, source }: DocumentWidgetProps) { function GenericPreview({ document }: DocumentWidgetProps) { return
{document.kind}
} +function TextPreview({ document, onMemoryCue }: DocumentWidgetProps) { + const excerpt = document.body.filter(Boolean).slice(0, 2).join(' ') + return
+

{excerpt || document.title}

+