feat: add brief concept party classification

This commit is contained in:
2026-08-14 14:44:58 +02:00
parent e333d3b634
commit d46a425401
17 changed files with 431 additions and 34 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ The current POC defines four exhibit families and corresponding frontend widgets
- **Note** — investigator-authored interpretation, visually distinct from source evidence.
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to supporting exhibits. The editor manages citations, dashed support lines keep them visually distinct from red investigative thread, and chronologically ordered events form the reconstructed-story strip.
The planned **Party** family adds distinct Person and Organization exhibits (businesses are organizations). Their dossier-like widgets reveal normalized evidence associations, but parties remain identity objects rather than special folders.
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.
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.
+6 -6
View File
@@ -45,13 +45,13 @@ This is the ordered implementation roadmap following the accepted exhibit model.
### Party exhibits
- [ ] Add a Party exhibit supertype representing an investigation participant.
- [ ] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields.
- [ ] Add an Organization subtype covering businesses, public bodies, associations, and informal groups.
- [ ] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note.
- [x] Add a Party exhibit supertype representing an investigation participant.
- [x] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields.
- [x] Add an Organization subtype covering businesses, public bodies, associations, and informal groups.
- [x] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note.
- [ ] Add typed Party-to-Party relationships such as employment, ownership, membership, control, and representation.
- [ ] Build distinct Person and Organization widgets that can open as dossiers and reveal associated evidence without using folder ownership semantics.
- [ ] Include parties, aliases, evidence associations, and party relationships in template/level cloning.
- [x] Build distinct Person and Organization dossier presentations that reveal associated evidence without using folder ownership semantics.
- [x] Include brief concepts, parties, aliases, evidence associations, and party relationships in template/level cloning.
## Milestone 4: first playable mystery
+4 -2
View File
@@ -1,6 +1,6 @@
# GUPI OSINT Board: canonical exhibit data model
Status: accepted design foundation; the core schema, transactional template lifecycle, frontend exhibit registry, and Event workflow are implemented. Parties remain the next model expansion.
Status: accepted design foundation; the core schema, template lifecycle, frontend registry, Event workflow, brief concepts, and Party subtypes are implemented. Interactive Party-to-Party relationships remain roadmap work.
## Vocabulary
@@ -180,7 +180,7 @@ A deferred constraint trigger verifies that both exhibits belong to the same boa
In the frontend, the Event widget shows its occurrence time, narrative text, and evidence count. Opening or selecting it reveals its supporting exhibits. Lines between an event and its evidence visualize `event_evidence`; they are not ordinary folder containment bands.
### Party semantics (planned)
### Party and brief-concept semantics
A party is a person or organization that participates in the investigation. Businesses are organizations. A party is a first-class exhibit, not a special folder: identity and evidence association must not be represented as file ownership.
@@ -193,6 +193,8 @@ The intended normalized shape is a `party_exhibits` supertype with one-to-one `p
The frontend provides distinct Person and Organization widgets through the exhibit registry. They may visually behave like dossiers—opening one can reveal associated evidence—but that interaction is derived from `party_evidence`; it does not turn the party into a folder or cause evidence to be owned by or disappear into the party.
A name appearing in the level brief begins as a normalized `brief_concept`, not as an exhibit. The author may record the expected Party classification, which is omitted from play-mode API responses. When the investigator classifies a concept as Person or Organization, the level creates a Party exhibit and records it in `resolved_party_exhibit_id`. This keeps the reasoning action explicit: the game does not pre-create a correctly typed party and merely hide its widget.
## Assets and document content
`assets` stores immutable uploaded bytes, checksum, MIME type, original filename, and size. Multiple cloned document exhibits may reference one asset.
+19
View File
@@ -87,6 +87,25 @@ test('move, folder expansion, hand pan, pinch zoom, and reload persistence', asy
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch)
await page.getByRole('button', { name: 'BRIEF', exact: true }).click()
await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace')
const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' })
await personConcept.getByRole('button', { name: 'PERSON', exact: true }).click()
await expect(page.getByText('Edit person dossier')).toBeVisible()
await page.getByLabel('Party aliases').fill('A. A. L.')
await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check()
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
await expect(page.locator('.evidence-card.party')).toContainText('Ada Lovelace')
await page.getByRole('button', { name: 'BRIEF', exact: true }).click()
const organizationConcept = page.locator('.brief-concepts section').filter({ hasText: 'Difference Engine Bureau' })
await organizationConcept.getByRole('button', { name: 'ORGANIZATION', exact: true }).click()
await page.getByLabel('Organization type').selectOption('public_body')
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
await page.reload()
await expect(page.locator('.evidence-card.party')).toHaveCount(2)
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau'])
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
await page.getByLabel('Event title').fill('The browser clue was connected')
+83
View File
@@ -0,0 +1,83 @@
INSERT INTO osint.exhibit_types (id,name) VALUES ('party','Party');
CREATE TABLE osint.party_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
party_kind TEXT NOT NULL CHECK (party_kind IN ('person','organization')),
display_name TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.person_parties (
exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
given_name TEXT,
family_name TEXT
);
CREATE TABLE osint.organization_parties (
exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
organization_kind TEXT NOT NULL DEFAULT 'business'
CHECK (organization_kind IN ('business','public_body','association','informal_group','other'))
);
CREATE TABLE osint.party_aliases (
id UUID PRIMARY KEY,
party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
alias TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
UNIQUE (party_exhibit_id,alias)
);
CREATE TABLE osint.party_evidence (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
note TEXT,
PRIMARY KEY (party_exhibit_id,evidence_exhibit_id),
CHECK (party_exhibit_id <> evidence_exhibit_id),
FOREIGN KEY (board_id,party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
FOREIGN KEY (board_id,evidence_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
);
CREATE TABLE osint.party_relationship_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
directed BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO osint.party_relationship_types (id,name,directed) VALUES
('employment','Employment',TRUE), ('ownership','Ownership',TRUE),
('membership','Membership',TRUE), ('control','Control',TRUE),
('representation','Representation',TRUE), ('associated','Associated',FALSE);
CREATE TABLE osint.party_relationships (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
relationship_type_id TEXT NOT NULL REFERENCES osint.party_relationship_types(id),
from_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
to_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
note TEXT,
CHECK (from_party_exhibit_id <> to_party_exhibit_id),
FOREIGN KEY (board_id,from_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
FOREIGN KEY (board_id,to_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
);
CREATE TABLE osint.level_briefs (
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.brief_concepts (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
origin_concept_id UUID REFERENCES osint.brief_concepts(id) ON DELETE SET NULL,
label TEXT NOT NULL,
context_text TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
expected_party_kind TEXT CHECK (expected_party_kind IN ('person','organization')),
resolved_party_exhibit_id UUID REFERENCES osint.party_exhibits(exhibit_id) ON DELETE SET NULL,
UNIQUE (board_id,label),
FOREIGN KEY (board_id,resolved_party_exhibit_id) REFERENCES osint.exhibits(board_id,id)
);
COMMENT ON TABLE osint.brief_concepts IS 'Named concepts in the level brief which may be classified into Party exhibits by the investigator.';
COMMENT ON COLUMN osint.brief_concepts.expected_party_kind IS 'Author-only expected classification; omitted from play-mode API projections.';
+33 -3
View File
@@ -72,6 +72,12 @@ suite('level persistence API', () => {
const folderId = randomUUID()
const noteId = randomUUID()
const eventId = randomUUID()
const personConceptId = randomUUID()
const organizationConceptId = randomUUID()
state.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [
{ id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
{ id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' },
] }
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
state.evidence = [
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
@@ -92,6 +98,10 @@ suite('level persistence API', () => {
expect(loaded.viewport).toEqual(state.viewport)
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.brief.concepts).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }),
]))
const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
@@ -121,6 +131,15 @@ suite('level persistence API', () => {
})
const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true)
const personPartyId = randomUUID()
const organizationPartyId = randomUUID()
playerState.evidence.push(
{ id: personPartyId, type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as the correspondent.', aliases: ['A. A. L.'], relatedEvidenceIds: [documentId, noteId], x: 720, y: 250, width: 280 },
{ id: organizationPartyId, type: 'party', partyKind: 'organization', organizationKind: 'business', title: 'Analytical Engines Ltd', content: 'Issued the filing.', aliases: ['AEL'], relatedEvidenceIds: [documentId], x: 1020, y: 250, width: 280 },
)
playerState.brief.concepts = playerState.brief.concepts.map(concept => ({ ...concept,
resolvedPartyExhibitId: concept.id === personConceptId ? personPartyId : organizationPartyId }))
playerState.viewport = { x: -150, y: 88, zoom: 1.1 }
playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 }
const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
@@ -136,7 +155,7 @@ suite('level persistence API', () => {
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string }>(`SELECT
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string; parties: string; people: string; organizations: string; party_evidence: string; concepts: string; hidden_answers: string }>(`SELECT
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
@@ -145,8 +164,14 @@ suite('level persistence API', () => {
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence`)
expect(normalized.rows[0]).toEqual({ exhibits: '5', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2' })
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence,
(SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties,
(SELECT COUNT(*) FROM osint.person_parties)::text AS people,
(SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations,
(SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence,
(SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts,
(SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers`)
expect(normalized.rows[0]).toEqual({ exhibits: '7', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2', parties: '2', people: '1', organizations: '1', party_evidence: '3', concepts: '2', hidden_answers: '2' })
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
expect(resetResponse.ok).toBe(true)
@@ -186,6 +211,11 @@ suite('level persistence API', () => {
expect(clonedEvent.supportingEvidenceIds).toHaveLength(2)
expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId)
expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId)
expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2)
expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true)
expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId)
const authoredClone = await (await fetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person'])
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
clonedFolder.x = 1234
+43
View File
@@ -10,6 +10,8 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
}
export async function clearBoard(client: PoolClient, boardId: string) {
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
@@ -65,6 +67,25 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[mapped(exhibitIds, row.exhibit_id, 'event'), row.title, row.narrative_text, row.occurred_at])
const parties = await client.query<{ exhibit_id: string; party_kind: string; display_name: string; summary: string }>(
`SELECT p.* FROM osint.party_exhibits p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of parties.rows) await client.query(
'INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
[mapped(exhibitIds, row.exhibit_id, 'party'), row.party_kind, row.display_name, row.summary])
const people = await client.query<{ exhibit_id: string; given_name: string | null; family_name: string | null }>(
`SELECT p.* FROM osint.person_parties p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of people.rows) await client.query('INSERT INTO osint.person_parties (exhibit_id,given_name,family_name) VALUES ($1,$2,$3)',
[mapped(exhibitIds, row.exhibit_id, 'person'), row.given_name, row.family_name])
const organizations = await client.query<{ exhibit_id: string; organization_kind: string }>(
`SELECT o.* FROM osint.organization_parties o JOIN osint.exhibits e ON e.id=o.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of organizations.rows) await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
[mapped(exhibitIds, row.exhibit_id, 'organization'), row.organization_kind])
const aliases = await client.query<{ party_exhibit_id: string; alias: string; sort_order: number }>(
`SELECT a.party_exhibit_id,a.alias,a.sort_order FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
WHERE e.board_id=$1 ORDER BY a.sort_order`, [sourceBoardId])
for (const row of aliases.rows) await client.query('INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)',
[randomUUID(), mapped(exhibitIds, row.party_exhibit_id, 'party alias'), row.alias, row.sort_order])
const blocks = await client.query<{ document_exhibit_id: string; sort_order: number; content: string }>(
`SELECT b.document_exhibit_id,b.sort_order,b.content FROM osint.document_content_blocks b
JOIN osint.exhibits e ON e.id=b.document_exhibit_id WHERE e.board_id=$1 ORDER BY b.sort_order`, [sourceBoardId])
@@ -113,6 +134,18 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[targetBoardId, mapped(exhibitIds, row.event_exhibit_id, 'event'), mapped(exhibitIds, row.evidence_exhibit_id, 'event evidence'), row.sort_order, row.note])
const partyEvidence = await client.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
'SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1', [sourceBoardId])
for (const row of partyEvidence.rows) await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[targetBoardId, mapped(exhibitIds, row.party_exhibit_id, 'party'), mapped(exhibitIds, row.evidence_exhibit_id, 'party evidence'), row.sort_order, row.note])
const partyRelationships = await client.query<{ relationship_type_id: string; from_party_exhibit_id: string; to_party_exhibit_id: string; note: string | null }>(
'SELECT relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note FROM osint.party_relationships WHERE board_id=$1', [sourceBoardId])
for (const row of partyRelationships.rows) await client.query(`INSERT INTO osint.party_relationships
(id,board_id,relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note) VALUES ($1,$2,$3,$4,$5,$6)`,
[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])
for (const row of connections.rows) await client.query(`INSERT INTO osint.exhibit_connections
@@ -127,5 +160,15 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
[mapped(exhibitIds, row.exhibit_id, 'sourced exhibit'), mapped(exhibitIds, row.source_document_exhibit_id, 'source document'),
row.source_region_id ? mapped(regionIds, row.source_region_id, 'source region') : null])
const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId])
if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body])
const concepts = await client.query<{
id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null
}>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId])
for (const row of concepts.rows) await client.query(`INSERT INTO osint.brief_concepts
(id,board_id,origin_concept_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
[randomUUID(), targetBoardId, row.id, row.label, row.context_text, row.sort_order, row.expected_party_kind,
row.resolved_party_exhibit_id ? mapped(exhibitIds, row.resolved_party_exhibit_id, 'resolved party') : null])
return exhibitIds
}
+4
View File
@@ -40,6 +40,10 @@ const created = await fetch(`${baseUrl}/api/levels`, {
})
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
const state = await created.json() as CaseState
state.brief = { body: 'Classify the named people and organizations in this investigation.', concepts: [
{ id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
{ id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' },
] }
state.documents = [{
id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {},
+68 -10
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg'
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from '../src/types.js'
import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
@@ -26,11 +26,12 @@ type LevelRow = {
source_template_version_id: string | null
}
type ExhibitRow = {
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event'; xpos: number; ypos: number; width: number; hidden: boolean
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
original_name: string | null; mime_type: string | null; byte_size: string | null
source_document_id: string | null; source_region_key: string | null
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
}
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@@ -59,14 +60,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
return result.rows[0] || null
}
async function assembleLevel(slug: string): Promise<CaseState | null> {
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult] = await Promise.all([
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content,
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
p.party_kind, op.organization_kind,
a.original_name, a.mime_type, a.byte_size,
s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
FROM osint.exhibits e
@@ -74,6 +77,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
LEFT JOIN osint.assets a ON a.id = d.asset_id
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
@@ -96,6 +101,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; alias: string }>(
`SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1
ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
`SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
])
const blocks = new Map<string, string[]>()
@@ -109,6 +124,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
const contained = new Map<string, string[]>()
const eventEvidence = new Map<string, string[]>()
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
const aliases = new Map<string, string[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const partyEvidence = new Map<string, string[]>()
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id])
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
@@ -127,12 +146,17 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
supportingEvidenceIds: eventEvidence.get(row.id) || [],
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined,
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [],
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
}))
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 })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
levelStatus: level.status, editingAllowed: editingEnabled, sourceTemplateVersionId: level.source_template_version_id || undefined }
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled,
sourceTemplateVersionId: level.source_template_version_id || undefined }
}
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
@@ -163,6 +187,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}
const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id])
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
@@ -170,9 +196,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.folder_memberships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'document_exhibits']) {
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'document_exhibits']) {
await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
}
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
@@ -195,7 +225,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}
for (const [index, exhibit] of state.evidence.entries()) {
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
const canonicalType = type === 'folder' || type === 'note' || type === 'event' ? type : 'note'
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
@@ -207,6 +237,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
if (canonicalType === 'event') await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate) || new Date().toISOString()])
if (canonicalType === 'party') {
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
[exhibit.id, partyKind, exhibit.title, exhibit.content])
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
else await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
[exhibit.id, exhibit.organizationKind || 'business'])
for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query(
'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder])
}
}
for (const relation of relationList.filter(item => item.type === 'contains')) {
@@ -222,6 +262,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
[level.board_id, event.id, evidenceId, sortOrder])
}
}
for (const party of state.evidence.filter(item => item.type === 'party')) {
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) {
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit')
await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, party.id, evidenceId, sortOrder])
}
}
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')
@@ -248,6 +296,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
[document.id, fields.get(key), document.metadata[key]])
}
const brief = state.brief || { body: '', concepts: [] }
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id')
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
const expected = concept.expectedPartyKind || expectedConceptKinds.get(concept.id) || null
await client.query(`INSERT INTO osint.brief_concepts
(id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null])
}
}
return {
@@ -329,7 +387,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return templateSummary(input.slug)
},
getLevel(levelId) { return assembleLevel(levelId) },
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state) {
const client = await pool.connect()
try {
+4 -3
View File
@@ -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(6)
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(7)
const client = new Client({ connectionString: testDatabaseUrl })
await client.connect()
@@ -42,15 +42,16 @@ suite('PostgreSQL migrations', () => {
expect(tableNames).toEqual(expect.arrayContaining([
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
]))
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('6')
expect(ledger.rows[0].count).toBe('7')
await client.end()
const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(6)
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(7)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
})
})
+117 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
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, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
@@ -33,6 +33,9 @@ export function App() {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -118,6 +121,23 @@ export function App() {
setSelected(event.id); setEditingEventId(event.id)
}
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
if (!caseState) return
const concept = caseState.brief.concepts.find(item => item.id === conceptId)
if (!concept) return
const existingId = concept.resolvedPartyExhibitId
const partyId = existingId || uid('party')
const { viewport } = caseState
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), width: 280 }
update(state => ({ ...state,
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
}))
setSelected(partyId); setBriefOpen(false); setEditingPartyId(partyId)
}
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))) {
@@ -209,7 +229,7 @@ export function App() {
<header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
</nav>
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
</header>
@@ -230,7 +250,16 @@ 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} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} />
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')}
canEdit={requestedEditMode && Boolean(caseState.editingAllowed)}
onClose={() => setBriefOpen(false)}
onEdit={() => setEditingBrief(true)}
onClassify={classifyConcept}
onLocate={focusEvidence}
/>}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate!.slice(0, 10)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
<div className="board-actions">
@@ -263,6 +292,27 @@ export function App() {
onClose={() => setEditingEventId(null)}
onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
/>}
{editingPartyId && <PartyEditor
key={editingPartyId}
party={caseState.evidence.find(item => item.id === editingPartyId)!}
evidence={caseState.evidence}
documents={caseState.documents}
onClose={() => setEditingPartyId(null)}
onSave={party => {
update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) }))
setEditingPartyId(null)
setStatus('PARTY DOSSIER UPDATED')
}}
/>}
{editingBrief && <BriefEditor
brief={caseState.brief}
onClose={() => setEditingBrief(false)}
onSave={brief => {
update(state => ({ ...state, brief }))
setEditingBrief(false)
setStatus('LEVEL BRIEF UPDATED')
}}
/>}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
</main>
}
@@ -282,7 +332,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, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; 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 }) {
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; 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 }) {
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 byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
@@ -339,6 +389,20 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
return [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
</svg>
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => {
const evidence = byId.get(evidenceId)
let target = evidence ? connectionPoint(evidence) : undefined
if (!target) {
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
}
if (!target) return []
const origin = connectionPoint(party)
return [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
</svg>
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{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 <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
</svg>
@@ -347,7 +411,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag() }}
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) }}>
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent}/>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
</article>})}
{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 <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
onPointerDown={event => { 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 }) }}
@@ -411,6 +475,53 @@ function localDateTime(value?: string) {
return local.toISOString().slice(0, 16)
}
function BriefPanel({ brief, parties, canEdit, onClose, onEdit, onClassify, onLocate }: { brief: LevelBrief; parties: Evidence[]; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void }) {
const partyById = new Map(parties.map(party => [party.id, party]))
return <aside className="brief-panel"><header><div><small>LEVEL BRIEF</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={resolved ? 'resolved' : ''} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <button className="resolved-party" onClick={() => onLocate(resolved.id)}>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()} · LOCATE</button> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
</aside>
}
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
const [body, setBody] = useState(brief.body)
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
<div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
<div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
<p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE BRIEF</button></div>
</div>
</form></div>
}
function PartyEditor({ party, evidence, documents, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (party: Evidence) => void }) {
const [name, setName] = useState(party.title)
const [summary, setSummary] = useState(party.content)
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
const [related, setRelated] = useState(party.relatedEvidenceIds || [])
const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))]
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, title: name.trim() || party.title, content: summary.trim(), organizationKind: party.partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}>
<header>{party.partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>Edit {party.partyKind} dossier</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>PARTY EXHIBIT · {party.partyKind?.toUpperCase()}</small>
<label className="field"><span>DISPLAY NAME</span><input aria-label="Party name" value={name} onChange={event => setName(event.target.value)}/></label>
<label className="field"><span>DOSSIER SUMMARY</span><textarea aria-label="Party summary" rows={3} value={summary} onChange={event => setSummary(event.target.value)}/></label>
{party.partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
<label className="field"><span>ALIASES · ONE PER LINE</span><textarea aria-label="Party aliases" rows={2} value={aliases} onChange={event => setAliases(event.target.value)}/></label>
<div className="folder-members-heading"><div><b>ASSOCIATED EVIDENCE</b><small>{related.length} LINKED</small></div><span>PARTY DOSSIER</span></div>
<div className="folder-members">{candidates.map(candidate => { const included = related.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'ASSOCIATED' : 'NOT LINKED'}</span></div> })}</div>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE DOSSIER</button></div>
</div>
</form></div>
}
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, members: string[]) => void }) {
const [title, setTitle] = useState(folder.title)
const [content, setContent] = useState(folder.content)
+1
View File
@@ -27,6 +27,7 @@ const folder: Evidence = {
}
const state: CaseState = {
brief: { body: '', concepts: [] },
id: 'test-level',
title: 'Test',
subtitle: '',
+1
View File
@@ -79,6 +79,7 @@ export function normalizeCase(state: CaseState): CaseState {
const normalized = { ...state, relations }
return {
...normalized,
brief: state.brief || { body: '', concepts: [] },
documents: state.documents.map(document => ({
...document,
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'),
+1 -1
View File
@@ -4,7 +4,7 @@ import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetReg
describe('frontend exhibit registry', () => {
it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => {
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event']
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event', 'party']
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort())
expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder'))
expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED')
+12 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react'
import { BookOpen, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil } from 'lucide-react'
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
import { folderIsOpen } from './boardDomain'
@@ -10,6 +10,7 @@ export type ExhibitWidgetProps = {
onToggleFolder: (id: string) => void
onEditFolder: (id: string) => void
onEditEvent: (id: string) => void
onEditParty: (id: string) => void
}
export type ExhibitWidgetDefinition = {
@@ -41,6 +42,15 @@ function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
</div>
}
function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) {
const person = exhibit.partyKind === 'person'
return <div className="card-content"><div className="party-identity">{person ? <UserRound size={28}/> : <Building2 size={28}/>}<div><h3>{exhibit.title}</h3><small>{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_', ' ').toUpperCase()}</small></div></div>
<p>{exhibit.content || 'No dossier summary yet.'}</p>
{(exhibit.aliases?.length || 0) > 0 && <div className="party-aliases">AKA · {exhibit.aliases!.join(' · ')}</div>}
<div className="event-actions"><span>{exhibit.relatedEvidenceIds?.length || 0} ASSOCIATED EXHIBITS</span><button onClick={() => onEditParty(exhibit.id)}><Pencil size={12}/> EDIT DOSSIER</button></div>
</div>
}
const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 })
const folderDefinition: ExhibitWidgetDefinition = {
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`,
@@ -52,6 +62,7 @@ export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition
evidence: folderDefinition,
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget },
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget },
party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget },
}
export function exhibitWidget(type: EvidenceType) {
+16
View File
@@ -54,6 +54,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
.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; }
.party-association-lines line { stroke: #6fa694; stroke-width: 2; stroke-dasharray: 3 5; opacity: .52; filter: drop-shadow(1px 1px 0 #020907); }
.folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; }
.folder-bands line.closed { opacity: 0; }
@@ -77,6 +79,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.event-actions { clear: both; margin-top: 10px; padding-top: 7px; border-top: 1px dashed #858c84; display: flex; align-items: center; justify-content: space-between; }
.event-actions span { color: #6a736d; font: 7px IBM Plex Mono; }
.event-actions button { float: none; }
.evidence-card.party { min-height: 190px; background: linear-gradient(110deg, #d9d7c9, #c8c8bd); border-top: 5px solid #315a50; }
.evidence-card.party h3 { margin: 0 0 3px; color: #254d43; }
.party-identity { display: grid; grid-template-columns: 34px 1fr; gap: 8px; align-items: center; padding: 11px 0 8px; border-bottom: 1px solid #929a92; }
.party-identity > svg { color: #315a50; }
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -123,6 +132,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.story-strip time { grid-row: 1 / 3; color: #7b938b; font: 7px IBM Plex Mono; }
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
.brief-panel { position: absolute; z-index: 12; right: 18px; top: 18px; width: min(410px, 42vw); max-height: calc(100% - 36px); overflow: auto; background: #c6c9c1; color: #17231f; border: 2px solid #d8dbd4; box-shadow: 7px 9px 0 #020a08, 0 0 0 1px #46554f; }
.brief-panel > header { height: 44px; padding: 0 8px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; }
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { margin-left: auto; width: 25px; height: 24px; display: grid; place-items: center; }
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
.classify-actions { display: flex; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-party, .edit-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-party { margin-top: 8px; background: #bfcfbe; }.edit-brief { margin: 12px 15px; background: #234c41; color: white; }
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
+18 -1
View File
@@ -1,4 +1,6 @@
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event'
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event' | 'party'
export type PartyKind = 'person' | 'organization'
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
export interface DocumentRegion {
@@ -33,6 +35,10 @@ export interface Evidence {
sourceRegionId?: string
eventDate?: string
supportingEvidenceIds?: string[]
partyKind?: PartyKind
organizationKind?: OrganizationKind
aliases?: string[]
relatedEvidenceIds?: string[]
x: number
y: number
width: number
@@ -58,6 +64,16 @@ export interface Connection {
export interface Viewport { x: number; y: number; zoom: number }
export interface BriefConcept {
id: string
label: string
context: string
expectedPartyKind?: PartyKind
resolvedPartyExhibitId?: string
}
export interface LevelBrief { body: string; concepts: BriefConcept[] }
export interface CaseState {
id: string
title: string
@@ -67,6 +83,7 @@ export interface CaseState {
relations: WidgetRelation[]
connections: Connection[]
viewport: Viewport
brief: LevelBrief
updatedAt?: string
levelStatus?: string
sourceTemplateVersionId?: string