From f97dceb4fef73a076ec3ff06ffc1fcd2519b04c3 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 16:37:50 +0200 Subject: [PATCH] feat: add tagged red thread connections --- README.md | 2 + e2e/mystery.acceptance.spec.ts | 29 +++++- migrations/009_connection_tightness.sql | 6 ++ server/api.integration.test.ts | 4 +- server/boardClone.ts | 8 +- server/levelRepository.ts | 14 +-- server/migrations.integration.test.ts | 8 +- src/App.tsx | 119 ++++++++++++++++++++---- src/boardDomain.test.ts | 15 +++ src/boardDomain.ts | 15 +++ src/styles.css | 15 ++- src/types.ts | 3 + 12 files changed, 201 insertions(+), 37 deletions(-) create mode 100644 migrations/009_connection_tightness.sql diff --git a/README.md b/README.md index 9644d00..a0e4951 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ The current POC defines four exhibit families and corresponding frontend widgets The **Party** family has distinct Person and Organization subtypes (businesses are organizations). Names begin as concepts in the level brief. The investigator classifies each concept, which creates the appropriate dossier exhibit and records the resolution. Expected classifications remain author-only. Dossiers store aliases and normalized evidence associations, but parties remain identity objects rather than special folders. +**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag and a persisted tightness percentage. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again. + Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders. An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement. diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index cd69b88..fc9beeb 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -64,11 +64,28 @@ test('a cloned Glass Harbor level can be solved without modifying its template', page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.')) await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click()) - const mara = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Mara Voss', exact: true }) }) + const elias = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Elias Vale', exact: true }) }) const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' }) - await mara.click() - await page.getByRole('button', { name: 'CONNECT', exact: true }).click() - await waitForSave(page, () => movement.click()) + 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 elias.click() + await page.getByRole('button', { name: 'Red thread', exact: true }).click() + const boardBox = await page.locator('.board-viewport').boundingBox() + if (!boardBox) throw new Error('Board is not visible') + await page.mouse.move(boardBox.x + boardBox.width / 2, boardBox.y + boardBox.height / 2) + await expect(page.locator('.connections .thread-preview path')).toBeVisible() + const manifestBox = await manifest.boundingBox() + if (!manifestBox) throw new Error('Carrier Dispatch Manifest is not visible') + await page.mouse.move(manifestBox.x + manifestBox.width / 2, manifestBox.y + manifestBox.height / 2) + await manifest.click() + const threadEditor = page.locator('.thread-editor') + await expect(threadEditor.getByText('Elias Vale', { exact: true })).toBeVisible() + await expect(threadEditor.getByText('Carrier Dispatch Manifest · H&F 14', { exact: true })).toBeVisible() + await page.getByLabel('Thread tag').fill('Proof Elias is the driver') + await page.getByRole('slider', { name: 'Thread tightness', exact: true }).fill('85') + await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click()) + await expect(page.locator('.connections g.tightening path')).toBeVisible() await page.reload() await expect(page.locator('.evidence-card.party')).toHaveCount(6) @@ -77,6 +94,10 @@ test('a cloned Glass Harbor level can be solved without modifying its template', 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(1) + await expect(page.locator('.thread-tag')).toContainText('Proof Elias is the driver') + + const solved = await (await request.get(`/api/levels/${playable.id}`)).json() + expect(solved.connections).toContainEqual(expect.objectContaining({ label: 'Proof Elias is the driver', tightness: 85 })) const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[] expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 })) diff --git a/migrations/009_connection_tightness.sql b/migrations/009_connection_tightness.sql new file mode 100644 index 0000000..3a83d73 --- /dev/null +++ b/migrations/009_connection_tightness.sql @@ -0,0 +1,6 @@ +ALTER TABLE osint.exhibit_connections + ADD COLUMN tightness SMALLINT NOT NULL DEFAULT 65 + CHECK (tightness BETWEEN 0 AND 100); + +COMMENT ON COLUMN osint.exhibit_connections.label IS 'Investigator-authored tag describing the meaning of this thread'; +COMMENT ON COLUMN osint.exhibit_connections.tightness IS 'Visual thread tautness percentage from 0 (slack) to 100 (taut)'; diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 8c6b675..c8c18e5 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -86,7 +86,7 @@ suite('level persistence API', () => { { id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 }, ] state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }] - state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: noteId }] + state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85 }] const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', @@ -100,6 +100,7 @@ suite('level persistence API', () => { expect(loaded.timelineRange).toEqual(state.timelineRange) expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } }) + expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85 })) expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), @@ -216,6 +217,7 @@ suite('level persistence API', () => { expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id) expect(clone.evidence.map(item => item.id)).not.toContain(folderId) expect(clone.connections).toHaveLength(1) + expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, toEvidenceId: clone.documents[0].id }) expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' }) const clonedEvent = clone.evidence.find(item => item.type === 'event')! expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' }) diff --git a/server/boardClone.ts b/server/boardClone.ts index 3a388bf..be2b6d8 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -153,11 +153,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ [randomUUID(), targetBoardId, row.relationship_type_id, mapped(exhibitIds, row.from_party_exhibit_id, 'related party'), mapped(exhibitIds, row.to_party_exhibit_id, 'related party'), row.note]) - const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null }>( - 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId]) + const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number }>( + 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId]) for (const row of connections.rows) await client.query(`INSERT INTO osint.exhibit_connections - (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label) VALUES ($1,$2,$3,$4,$5,$6)`, - [randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label]) + (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label, row.tightness]) const sources = await client.query<{ exhibit_id: string; source_document_exhibit_id: string; source_region_id: string | null }>( `SELECT s.exhibit_id,s.source_document_exhibit_id,s.source_region_id FROM osint.exhibit_sources s diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 06ce8dd..08e8c9e 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -93,8 +93,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve `SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]), - pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string }>( - `SELECT id, from_exhibit_id, to_exhibit_id FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]), + pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number }>( + `SELECT id, from_exhibit_id, to_exhibit_id, label, tightness FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]), pool.query<{ exhibit_id: string; field_key: string; value: string }>( `SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]), @@ -155,7 +155,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text, ...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined })) return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations, - connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })), + connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id, + label: row.label || undefined, tightness: row.tightness })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled, @@ -281,9 +282,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve } for (const connection of state.connections) { requireUuid(connection.id, 'Connection id') - if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit') - await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id) - VALUES ($1,$2,'thread',$3,$4)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId]) + if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit') + const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65)))) + await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness) + VALUES ($1,$2,'thread',$3,$4,$5,$6)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness]) } for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) { if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document') diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index 2bdf27d..af9ed57 100644 --- a/server/migrations.integration.test.ts +++ b/server/migrations.integration.test.ts @@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => { const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const firstRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) - expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(8) + expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(9) const client = new Client({ connectionString: testDatabaseUrl }) await client.connect() @@ -47,12 +47,14 @@ suite('PostgreSQL migrations', () => { ])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs'])) const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') - expect(ledger.rows[0].count).toBe('8') + expect(ledger.rows[0].count).toBe('9') + const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) + expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness'])) await client.end() const secondRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) - expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(8) + expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(9) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) }) }) diff --git a/src/App.tsx b/src/App.tsx index 181c7d3..63e2ed2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' -import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types' -import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel } from './boardDomain' +import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types' +import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel } from './boardDomain' import { documentWidget, exhibitWidget } from './exhibitRegistry' const BOARD_W = 2400 @@ -39,6 +39,8 @@ export function App() { const [editingBrief, setEditingBrief] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false) const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState(null) + const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState(null) + const [threadDraft, setThreadDraft] = useState(null) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) @@ -76,6 +78,12 @@ export function App() { return () => window.clearTimeout(timer) }, [recentlyCreatedExhibitId]) + useEffect(() => { + if (!recentlyCreatedConnectionId) return + const timer = window.setTimeout(() => setRecentlyCreatedConnectionId(null), 1200) + return () => window.clearTimeout(timer) + }, [recentlyCreatedConnectionId]) + const update = useCallback((fn: (state: CaseState) => CaseState) => { setCaseState(current => { if (!current) return current @@ -163,10 +171,40 @@ export function App() { const handleCardClick = (id: string) => { if (!linkFrom) { setSelected(current => current === id ? null : id); return } - if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) { - update(s => ({ ...s, connections: [...s.connections, { id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: id }] })) + completeThread(id) + } + + const completeThread = (targetId: string) => { + if (!linkFrom || !caseState || linkFrom === targetId) return + const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom)) + if (existing) { + setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') + return } - setLinkFrom(null); setSelected(id) + setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65 }) + setLinkFrom(null) + if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId) + } + + const saveThread = (connection: Connection) => { + if (!caseState) return + const exists = caseState.connections.some(item => item.id === connection.id) + update(state => ({ ...state, connections: exists ? state.connections.map(item => item.id === connection.id ? connection : item) : [...state.connections, connection] })) + if (!exists) setRecentlyCreatedConnectionId(connection.id) + setThreadDraft(null) + setStatus(connection.label ? 'RED THREAD TAGGED' : 'RED THREAD TIGHTENED') + } + + const removeThread = (id: string) => { + update(state => ({ ...state, connections: state.connections.filter(connection => connection.id !== id) })) + setThreadDraft(null) + setStatus('RED THREAD REMOVED') + } + + const toggleThreadTool = () => { + if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') } + else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') } + setBoardTool('move') } const reset = async () => { @@ -274,7 +312,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'}
- 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)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> {briefOpen && item.type === 'party')} @@ -294,7 +332,7 @@ export function App() { - + {Math.round(caseState.viewport.zoom * 100)}% @@ -349,6 +387,16 @@ export function App() { setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC') }} />} + {threadDraft && item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'} + targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'} + isNew={!caseState.connections.some(item => item.id === threadDraft.id)} + onClose={() => setThreadDraft(null)} + onSave={saveThread} + onRemove={() => removeThread(threadDraft.id)} + />} {helpOpen && setHelpOpen(false)}/>} } @@ -368,13 +416,23 @@ 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, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => 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, 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 }) { const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) const pinchDistance = useRef(null) + const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null) const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence]) const containmentRelations = state.relations.filter(relation => relation.type === 'contains') + const pointForId = (id: string) => { + const evidence = byId.get(id) + if (evidence) return connectionPoint(evidence) + const relation = containmentRelations.find(item => item.toWidgetId === id) + const folder = relation ? byId.get(relation.fromWidgetId) : undefined + if (!relation || !folder) return undefined + const position = relationPosition(state, relation) + return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder) + } useEffect(() => { const board = boardRef.current if (!board) return @@ -405,7 +463,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boar drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y } try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } } + const trackThreadPointer = (event: React.PointerEvent) => { + if (linkFrom && boardRef.current) { + const bounds = boardRef.current.getBoundingClientRect() + setThreadPointer({ x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }) + } + } const pointerMove = (event: React.PointerEvent) => { + trackThreadPointer(event) if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) { touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (touchPoints.current.size >= 2) { @@ -435,14 +500,17 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boar drag.current = null } const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) })) - return
{ if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }} - onPointerMove={pointerMove} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}> + onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04
- {state.connections.map(c => { const a = byId.get(c.fromEvidenceId), b = byId.get(c.toEvidenceId); if (!a || !b) return null; const p1 = connectionPoint(a), p2 = connectionPoint(b); return })} + {state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return })} + {previewOrigin && threadPointer && } + {state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; return })} {state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { const evidence = byId.get(evidenceId) @@ -474,20 +542,20 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boar {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} - {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} + {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')} })} - {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); 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) }} onDoubleClick={() => open && onOpenSource(document.id)}> + {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); 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: '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 (open && linkFrom) onConnectionTarget(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
{document.title} -
+
})}
@@ -559,6 +627,21 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
} +function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) { + const [label, setLabel] = useState(connection.label || '') + const [tightness, setTightness] = useState(connection.tightness ?? 65) + const save = (tag = label) => onSave({ ...connection, label: tag.trim() || undefined, tightness }) + return
{ event.preventDefault(); save() }}> +
{isNew ? 'Add relation tag' : 'Edit red thread'}
+
RED THREAD · INVESTIGATOR RELATION
{sourceName}{targetName}
+

What does this connection mean? Add a short tag if the thread represents a specific claim.

+ + +
{!isNew && }{isNew && }
+
+
+} + function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { const partyById = new Map(parties.map(party => [party.id, party])) const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length @@ -719,4 +802,4 @@ function DocumentAsset({ doc }: { doc: CaseDocument }) { return } -function Help({ onClose }: { onClose: () => void }) { return
Field Manual
GU-NET QUICK START

Reconstruct what happened.

  1. Open a case document.
  2. Extract the highlighted clue.
  3. Drag evidence into meaningful groups.
  4. Select a card, choose Connect, then select its target.
  5. Use dated markers to move through the case.

The system will not announce your conclusion. Make it visible.

} +function Help({ onClose }: { onClose: () => void }) { return
Field Manual
GU-NET QUICK START

Reconstruct what happened.

  1. Open a case document.
  2. Extract the highlighted clue.
  3. Drag evidence into meaningful groups.
  4. Select an exhibit, choose the red-thread tool, then select its target.
  5. Tag the thread with the claim it represents.
  6. Use dated markers to move through the case.

The system will not announce your conclusion. Make it visible.

} diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index 0338a31..ac61428 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -11,12 +11,27 @@ import { normalizeCase, panViewport, relationPosition, + threadCurve, timelinePositionPercent, timelineRange, zoomFromWheel, zoomFromPinch, } from './boardDomain' +describe('red thread geometry', () => { + it('turns tightness into a taut or visibly sagging bezier curve', () => { + const taut = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 100) + const slack = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 0) + expect(taut.midpoint).toEqual({ x: 60, y: 30 }) + expect(slack.midpoint.y).toBeGreaterThan(taut.midpoint.y) + expect(slack.path).toMatch(/^M 0 0 C /) + }) + + it('clamps tightness to its normalized percentage range', () => { + expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100)) + }) +}) + const folder: Evidence = { id: 'folder-1', type: 'folder', diff --git a/src/boardDomain.ts b/src/boardDomain.ts index 57eb6c2..2f38e9e 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -1,5 +1,20 @@ import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types' +export interface BoardPoint { x: number; y: number } + +export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) { + const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100 + const distance = Math.hypot(to.x - from.x, to.y - from.y) + const sag = (1 - tautness) * Math.min(190, Math.max(45, distance * .28)) + const c1 = { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 + sag } + const c2 = { x: from.x + (to.x - from.x) * 2 / 3, y: from.y + (to.y - from.y) * 2 / 3 + sag } + const midpoint = { + x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, + y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + } + return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint } +} + export const MIN_BOARD_ZOOM = 0.45 export const MAX_BOARD_ZOOM = 1.5 diff --git a/src/styles.css b/src/styles.css index 5e350a9..7f19aa9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -47,12 +47,21 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; } .board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; } .board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; } +.board-viewport.threading, .board-viewport.threading .evidence-card, .board-viewport.threading .source-file-widget { cursor: crosshair; } .board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); } .board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; } .board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; } .connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; } .connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); } .connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; } +.connections .thread-preview path { stroke: #bc3b35; stroke-width: 2.5; stroke-dasharray: 8 6; opacity: .82; } +.connections .thread-preview circle:last-child { fill: #0d211c; stroke: #c84a43; } +.connections g.tightening path { stroke-dasharray: 1400; animation: thread-tighten .9s cubic-bezier(.15,.75,.22,1) both; } +@keyframes thread-tighten { 0% { stroke-dashoffset: 1400; filter: drop-shadow(0 0 7px #d85a50); } 70% { stroke-dashoffset: 0; stroke-width: 4; } 100% { stroke-dashoffset: 0; stroke-width: 3; } } +.thread-tag { position: absolute; z-index: 7; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; border: 0; background: transparent; color: #241d17; cursor: pointer; } +.thread-tag i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; } +.thread-tag span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; } +.thread-tag:hover i, .thread-tag:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; } .event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; } .event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); } .party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; } @@ -64,6 +73,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); } .evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; } .evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; } +.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; } .evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; } @keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } } .evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; } @@ -123,6 +133,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; } .board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; } .board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; } +.board-actions button.thread-tool:not(:disabled) { color: #d35b52; } +.board-actions button.thread-tool.active { background: #4a211f; color: #ff8a78; box-shadow: inset 0 0 0 1px #8f3934; } .board-actions button:disabled { opacity: .35; cursor: default; } .board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; } .board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; } @@ -159,6 +171,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; } .window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; } .timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; } +.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; } .window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; } .window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; } .document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; } @@ -212,4 +225,4 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; } .empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; } @media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } } -@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved { animation: none; }.board, .documents-panel { transition: none; } } +@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path { animation: none; }.board, .documents-panel { transition: none; } } diff --git a/src/types.ts b/src/types.ts index f5bc05b..714be9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,6 +60,9 @@ export interface Connection { id: string fromEvidenceId: string toEvidenceId: string + label?: string + /** Percentage from slack (0) to taut (100). */ + tightness?: number } export interface Viewport { x: number; y: number; zoom: number }