feat: add handwritten cues to text exhibits

This commit is contained in:
2026-08-14 18:30:31 +02:00
parent b39d2efa75
commit ffa81284df
4 changed files with 24 additions and 5 deletions
+5
View File
@@ -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)
+3 -3
View File
@@ -331,7 +331,7 @@ export function App() {
<div className="board-shell" onDragEnter={e => { 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) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => 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 && <BriefPanel
brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')}
@@ -452,7 +452,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
}
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<HTMLDivElement | null>; 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<HTMLDivElement | null>; 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<number, { x: number; y: number }>())
@@ -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)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source}/></div>
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
</article> })}
+10 -2
View File
@@ -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<DocumentWidgetProps>
@@ -82,6 +82,14 @@ function ImagePreview({ document, source }: DocumentWidgetProps) {
function GenericPreview({ document }: DocumentWidgetProps) {
return <div><ImageIcon size={35}/><small>{document.kind}</small></div>
}
function TextPreview({ document, onMemoryCue }: DocumentWidgetProps) {
const excerpt = document.body.filter(Boolean).slice(0, 2).join(' ')
return <div className="text-document-preview">
<p className="text-source-excerpt">{excerpt || document.title}</p>
<textarea aria-label={`Memory cue for ${document.title}`} maxLength={48} placeholder="WRITE A MEMORY CUE…" value={document.metadata.memory_cue || ''}
onPointerDown={event => event.stopPropagation()} onClick={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} onChange={event => onMemoryCue?.(event.target.value)}/>
</div>
}
function ImageAsset({ document, source }: DocumentWidgetProps) {
return <img className="document-image" src={source} alt={document.fileName || document.title}/>
}
@@ -96,7 +104,7 @@ const genericDocument = (label: string): DocumentWidgetDefinition => ({ label, P
export const documentWidgetRegistry: Record<SourceFileType, DocumentWidgetDefinition> = {
image: { label: 'Image', Preview: ImagePreview, Asset: ImageAsset },
pdf: { label: 'PDF', Preview: GenericPreview, Asset: FrameAsset },
text: { label: 'Text document', Preview: GenericPreview, Asset: FrameAsset },
text: { label: 'Text document', Preview: TextPreview, Asset: FrameAsset },
web_capture: genericDocument('Web capture'),
email: genericDocument('Email'),
article: genericDocument('Article'),
+6
View File
@@ -142,6 +142,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.source-file-preview img { width: 100%; height: 100%; object-fit: cover; }
.source-file-preview > div { display: grid; justify-items: center; gap: 5px; color: #b8c2bd; }
.source-file-preview small { font: 7px IBM Plex Mono; }
.source-file-widget.file-type-text .source-file-preview { height: 108px; display: block; background: #e8e4d7; border-color: #a19b8b; }
.text-document-preview { width: 100%; height: 100%; display: grid !important; grid-template-rows: 42px 1fr; justify-items: stretch !important; gap: 0 !important; color: #202722 !important; }
.text-source-excerpt { margin: 0; padding: 6px 7px; overflow: hidden; border-bottom: 1px solid #a29d8e; background: #d5d1c4; font: 6px/1.35 IBM Plex Mono; text-transform: uppercase; }
.text-document-preview textarea { width: 100%; min-height: 0; resize: none; overflow: hidden; border: 0; outline: 0; padding: 8px 7px 5px; background: repeating-linear-gradient(#ede9dc 0 20px, #c9c5b855 21px); color: #151b18; font: 900 15px/1.05 "Marker Felt", "Comic Sans MS", cursive; letter-spacing: .015em; text-align: center; transform: rotate(-1deg); }
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }