Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board

This commit is contained in:
2026-08-22 19:54:15 +02:00
6 changed files with 114 additions and 10 deletions
+20
View File
@@ -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')).toHaveCount(3)
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau', 'Mara Elise Voss']) 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 page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
await expect(page.getByText('Edit reconstructed event')).toBeVisible() await expect(page.getByText('Edit reconstructed event')).toBeVisible()
await page.getByLabel('Event title').fill('The browser clue was connected') await page.getByLabel('Event title').fill('The browser clue was connected')
+27 -3
View File
@@ -2,8 +2,9 @@ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRe
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 { 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 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 { 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 { 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' import type { PlaythroughState } from './narrative'
// three.js stays out of the board bundle until the player opens the inventory. // three.js stays out of the board bundle until the player opens the inventory.
@@ -954,18 +955,41 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header> <header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} context={widgetContext}/> <Widget exhibit={ev} context={widgetContext}/>
</article>})} </article>})}
{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 <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }} {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 identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
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: 'widget', id: document.id }) }} 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: '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)}> 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)}>
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header> <header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></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?.slice(0, 10) || 'UNDATED'}</time> {document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : <strong>{document.title}</strong>}<time>{document.publishedAt?.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> <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> })} </article> })}
</div> </div>
</div> </div>
} }
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 <strong className={`mugshot-caption ${writing ? 'writing' : ''}`} aria-label={name ? `Identified as ${name}` : 'Unlabelled mugshot'}><span>{visibleName}</span></strong>
}
function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) { function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null) const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
+38
View File
@@ -21,6 +21,22 @@ function noise(context: AudioContext) {
return noiseBuffer 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 const music = typeof Audio !== 'undefined' ? new Audio() : null
if (music) music.loop = true if (music) music.loop = true
let currentUrl: string | null = null let currentUrl: string | null = null
@@ -100,6 +116,28 @@ export const audio = {
src.connect(filter).connect(gain).connect(context.destination) src.connect(filter).connect(gain).connect(context.destination)
src.start(now); src.stop(now + 0.04) 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 the context and retry pending music on a user gesture.
resume() { resume() {
void audioContext()?.resume?.() void audioContext()?.resume?.()
+5 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { DocumentCaptureKind, DocumentExhibit, SourceFileType } from './types' 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', () => { describe('frontend exhibit registry', () => {
it('registers every normalized exhibit type', () => { 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 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 } 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(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…') 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()
}) })
}) })
+18 -3
View File
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react' import type { ComponentType } from 'react'
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-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 = export type WidgetCommand =
| { type: 'open-document'; documentId: string } | { type: 'open-document'; documentId: string }
@@ -142,20 +142,35 @@ export type DocumentCaptureDefinition = {
} }
export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureDefinition> = { export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureDefinition> = {
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} }, 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} }, 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} }, 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} }, 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 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. */ /** Context-sensitive starter copy. A Party is never treated as proof by default. */
export function defaultConnectionLabel(first:Exhibit,second:Exhibit) { export function defaultConnectionLabel(first:Exhibit,second:Exhibit) {
const types = new Set([first.type,second.type]) const types = new Set([first.type,second.type])
if (types.has('party') && types.has('claim')) return 'Subject of claim' if (types.has('party') && types.has('claim')) return 'Subject of claim'
const document = first.type === 'document' ? first : second.type === 'document' ? second : null const document = first.type === 'document' ? first : second.type === 'document' ? second : null
if (document && types.has('party')) { if (document && types.has('party')) {
if (document.captureKind === 'photo') return 'Depicts…' if (document.captureKind === 'photo') return 'Identified as…'
if (document.captureKind === 'scene') return 'Shows…' if (document.captureKind === 'scene') return 'Shows…'
return 'Concerns…' return 'Concerns…'
} }
+6 -2
View File
@@ -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-clipping:hover .source-file-actions,
.source-file-widget.capture-kind-full_page: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.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.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 { 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 .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 { 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-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 { padding: 9px 9px 10px; background: #e5e0d3; }
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); } .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 button { flex: 0 0 36px; width: 36px; justify-content: center; }
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; } .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 */ /* 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; } .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; }