diff --git a/docs/exhibit-data-model.md b/docs/exhibit-data-model.md index 6488f2d..dbc5be3 100644 --- a/docs/exhibit-data-model.md +++ b/docs/exhibit-data-model.md @@ -8,6 +8,7 @@ Status: accepted design foundation; the core schema, template lifecycle, fronten - An **exhibit type** describes its domain behavior: folder, document, clipping, note, event, party, or conclusion. - A **widget** is the frontend visualization and interaction implementation selected for an exhibit type. - A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file. +- A **capture kind** describes how imported image evidence is understood and physically presented: photo, scene, clipping, full page, or not yet classified. It is independent of file format and document type. - A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards. - A board may define a temporal viewport (`board_timeline_settings`). If absent, the client derives a range from dated evidence; if present, the range clones and resets with the board. - A **level** is a mutable board copy used for either play or authoring. @@ -121,9 +122,17 @@ CREATE TABLE osint.document_types ( name TEXT NOT NULL UNIQUE ); +CREATE TABLE osint.document_capture_kinds ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL +); + CREATE TABLE osint.document_exhibits ( exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE, document_type_id TEXT NOT NULL REFERENCES osint.document_types(id), + capture_kind_id TEXT NOT NULL DEFAULT 'unclassified' + REFERENCES osint.document_capture_kinds(id), asset_id UUID REFERENCES osint.assets(id), title TEXT NOT NULL, published_at TIMESTAMPTZ, @@ -296,7 +305,7 @@ Red investigative thread is derived from `exhibit_connections`. Extraction prove ## Document metadata -Known, semantically important values remain real columns: `published_at`, `captured_at`, and `source_uri`. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table. +Known, semantically important values remain real columns: `published_at`, `captured_at`, `source_uri`, and `capture_kind_id`. Capture kind is player-selected source interpretation used for physical board presentation and contextual connection copy; it never changes the immutable asset, OCR text, provenance, MIME type, or document type. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table. ```sql CREATE TABLE osint.metadata_fields ( @@ -323,7 +332,7 @@ The following are computed and must not become duplicate source-of-truth tables: - Grey timeline projection: document exhibit position to `published_at` on the timeline. - Pale red containment band: folder position to contained exhibit position. - Folder flight animation: closed folder position to the exhibit's stored `xpos` and `ypos`. -- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry. +- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry. For imported image documents, `capture_kind` selects a physical presentation variant within that document widget. ## Transactional operations diff --git a/e2e/scene7.acceptance.spec.ts b/e2e/scene7.acceptance.spec.ts index 39229b8..638a47b 100644 --- a/e2e/scene7.acceptance.spec.ts +++ b/e2e/scene7.acceptance.spec.ts @@ -42,11 +42,27 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene expect(uploaded.analysis).toMatchObject({ extractionStatus:'succeeded',matchedFlags:['scene7.nils_inventor_proved'], goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:true })] }) + await expect(page.getByRole('dialog',{ name:'What kind of evidence is this?' })).toBeVisible() + await page.getByRole('button',{ name:'Classify as Clip' }).click() await expect(page.locator('.source-file-widget')).toHaveCount(1) + await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping') await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/) await expect(page.locator('.goal-complete-card')).toHaveCount(0) await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor') + await page.locator('.source-file-widget').dblclick() + await page.getByRole('button',{ name:'TYPE',exact:true }).click() + await page.getByRole('menuitemradio',{ name:/Mugshot/ }).click() + await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','photo') + await page.getByRole('button',{ name:'TYPE',exact:true }).click() + await page.getByRole('menuitemradio',{ name:/Clip/ }).click() + await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping') + await page.getByRole('button',{ name:'FILE',exact:true }).click() + await page.getByRole('menuitem',{ name:/INFO/ }).click() + await expect(page.getByLabel('Board presentation')).toHaveValue('clipping') + await page.getByRole('button',{ name:'Close file editor' }).click() + await page.getByRole('button',{ name:'Close document' }).click() + await page.locator('.evidence-card.claim').click() await page.getByRole('button',{ name:'Red thread' }).click() await page.locator('.source-file-widget').click() @@ -71,6 +87,8 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json() expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })]) - expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toHaveLength(1) + expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toEqual([ + expect.objectContaining({ captureKind:'clipping',width:210,height:194 }), + ]) expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' }) }) diff --git a/migrations/035_document_capture_kinds.sql b/migrations/035_document_capture_kinds.sql new file mode 100644 index 0000000..0872150 --- /dev/null +++ b/migrations/035_document_capture_kinds.sql @@ -0,0 +1,23 @@ +-- How an imported document is presented on the physical investigation board. +-- This is deliberately separate from document_type_id/MIME type: the same PNG +-- may be a photograph, a scene, a clipping, or a complete page. + +CREATE TABLE osint.document_capture_kinds ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL +); + +INSERT INTO osint.document_capture_kinds (id,name,description) VALUES + ('unclassified','Unclassified','Imported evidence that has not yet been classified.'), + ('photo','Photo','A person or object is the subject of the image.'), + ('scene','Scene','A place, situation, or event is shown.'), + ('clipping','Clipping','An extract captured from a larger source.'), + ('full_page','Full page','A complete page or document view.'); + +ALTER TABLE osint.document_exhibits + ADD COLUMN capture_kind_id TEXT NOT NULL DEFAULT 'unclassified' + REFERENCES osint.document_capture_kinds(id); + +COMMENT ON COLUMN osint.document_exhibits.capture_kind_id IS + 'Player-selected evidentiary form used by board presentation and contextual connection copy; independent of the asset MIME type.'; diff --git a/mysteries/barricelli-scene-7/mystery.json b/mysteries/barricelli-scene-7/mystery.json new file mode 100644 index 0000000..a6e9453 --- /dev/null +++ b/mysteries/barricelli-scene-7/mystery.json @@ -0,0 +1,104 @@ +{ + "slug": "barricelli-inventor-proof", + "name": "Scene 7 · Prove Barricelli was an inventor", + "title": "The Barricelli Files", + "subtitle": "Scene 7 · Demonstrate OSINT skill", + "brief": { + "body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, and paste it directly onto this board. Connect the source to the authored claim with red thread, explain what the evidence proves, and submit the Case Report.", + "concepts": [] + }, + "narrative": { + "cast": [], + "graph": { + "entry": "prove-inventor", + "nodes": [ + { + "key": "prove-inventor", + "type": "level", + "label": "Demonstrate OSINT skill", + "templateSlug": "barricelli-inventor-proof", + "x": 200, + "y": 80, + "terminals": [ + { "key": "report_back", "label": "Submit finding", "to": "barricelli-luggage" } + ] + }, + { + "key": "barricelli-luggage", + "type": "merit", + "label": "The Barricelli Luggage", + "awardsFlag": "barricelli_luggage", + "x": 200, + "y": 300, + "terminals": [ + { "key": "continue", "label": "Accept", "to": null } + ] + } + ] + } + }, + "documents": [], + "folders": [], + "claims": [ + { + "key": "nils-inventor", + "statement": "Nils Aall Barricelli was an inventor.", + "x": 940, + "y": 360, + "width": 330, + "height": 190 + } + ], + "report": { + "title": "Barricelli Inventor Finding", + "requiredForCompletion": true + }, + "goals": [ + { + "key": "barricelli.inventor-proof", + "title": "Prove Nils Aall Barricelli was an inventor", + "instructions": "Paste a reliable screenshot, connect it to the claim, and submit a properly cited Case Report.", + "completionMessage": "CASE REPORT ACCEPTED — NILS AALL BARRICELLI: INVENTOR", + "requiredFlags": ["scene7.nils_inventor_proved"] + } + ], + "evidenceMatchRules": [ + { + "name": "Google Patents · GB695913A", + "sourceLabel": "Google Patents · GB695913A · Improved chest of drawers", + "sourceUri": "https://patents.google.com/patent/GB695913A/en", + "flagKey": "scene7.nils_inventor_proved", + "minimumAnchorMatches": 2, + "anchors": [ + { "phrase": "GB695913A Improved chest of drawers", "minimumSimilarity": 0.7 }, + { "phrase": "Nils Aall Barricelli", "minimumSimilarity": 0.72 }, + { "phrase": "695913 Chests of drawers BARRICELLI N A May 31 1951", "minimumSimilarity": 0.68 } + ] + }, + { + "name": "Nasjonalbiblioteket · Patent 93585", + "sourceLabel": "Nasjonalbiblioteket · Patent 93585 · Koffert-kommode", + "sourceUri": "https://www.nb.no/items/921545b51acef0b05054cfc1f4666975?page=9&searchText=baricelli", + "flagKey": "scene7.nils_inventor_proved", + "minimumAnchorMatches": 2, + "anchors": [ + { "phrase": "Nr 75 348 Kl 33 b-9 Fra 31 mai 1948 93585 Koffert-kommode", "minimumSimilarity": 0.68 }, + { "phrase": "Niels Aall Baricelli Oslo", "minimumSimilarity": 0.72 }, + { "phrase": "Patentpaastand Kommode som er satt sammen av flere enkeltdeler som hver er utfort som koffert", "minimumSimilarity": 0.62 } + ] + } + ], + "evidenceSemanticRules": [ + { + "goalKey": "barricelli.inventor-proof", + "name": "Barricelli inventor claim", + "targetSubject": "Nils Aall Barricelli", + "relatedSubject": "Nils Aall Barricelli's father", + "assertion": "The source states or directly demonstrates that Nils Aall Barricelli was an inventor or a named patent applicant for an invention.", + "successFlagKey": "scene7.nils_inventor_proved", + "relatedFlagKey": "scene7.father_inventor_discovered", + "minimumConfidence": 0.88, + "evaluatorVersion": "barricelli_inventor_v1" + } + ] +} diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index 8e2e348..9987aef 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -2,12 +2,13 @@ import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import type { CaseDocument, CaseState, ClaimExhibit, PartyKind, SourceFileType } from '../src/types.js' +import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, PartyKind, SourceFileType } from '../src/types.js' type MysteryDocument = { key: string title: string fileType: SourceFileType + captureKind?: DocumentCaptureKind publishedAt: string body?: string[] metadata?: Record @@ -98,7 +99,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false, body: source.body || [], regions: [], assetId: uploaded?.assetId, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, - fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [], + fileType: source.fileType, captureKind:source.captureKind || 'unclassified', metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [], }) } diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index f0b993d..7985271 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -101,8 +101,8 @@ suite('normalized level persistence API', () => { timeline.range = { start: '2021-04-01', end: '2021-04-30' } state.viewport = { x: 91, y: -42, zoom: 0.85 } - const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', 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: {}, ...placed(1051, 417, 174, 145, 2) } - const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) } + const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', 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', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) } + const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) } const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) } const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) } const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) } @@ -123,6 +123,7 @@ suite('normalized level persistence API', () => { const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range }) expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true }) + expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 }) expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })])) expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' }) expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] }) @@ -221,7 +222,7 @@ suite('normalized level persistence API', () => { screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png') const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot }) expect(screenshotResponse.status).toBe(201) - expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' }) + expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image',captureKind:'unclassified',fileName: 'Screenshot 2026-08-22.png' }) const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) }) expect(templateResponse.status).toBe(201) @@ -235,7 +236,7 @@ suite('normalized level persistence API', () => { expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 }) expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id) const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState - expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] }) + expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] }) expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([ expect.objectContaining({ name: 'Smoke source passage', sourceLabel: 'Archive smoke test', sourceUri: 'https://example.test/archive/smoke', flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }), diff --git a/server/boardClone.ts b/server/boardClone.ts index 7d0a795..de6d326 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -66,11 +66,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ const documents = await client.query<{ exhibit_id: string; document_type_id: string; asset_id: string | null; title: string - published_at: Date | null; captured_at: Date | null; source_uri: string | null; citation_text:string + capture_kind_id:string; published_at: Date | null; captured_at: Date | null; source_uri: string | null; citation_text:string }>(`SELECT d.* FROM osint.document_exhibits d JOIN osint.exhibits e ON e.id=d.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) for (const row of documents.rows) await client.query(`INSERT INTO osint.document_exhibits - (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text]) + (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.capture_kind_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text]) const citations = await client.query<{ exhibit_id:string;display_number:number }>( 'SELECT exhibit_id,display_number FROM osint.exhibit_citations WHERE board_id=$1 ORDER BY display_number',[sourceBoardId]) diff --git a/server/e2eHarness.ts b/server/e2eHarness.ts index ee04523..cc48137 100644 --- a/server/e2eHarness.ts +++ b/server/e2eHarness.ts @@ -53,7 +53,7 @@ state.brief = { body: 'Classify the named people and organizations in this inves ] } state.exhibits = [{ id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z', - body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, + body: [], regions: [], fileType: 'image', captureKind:'scene',metadata: {}, x: 980, y: 360, width: 244, height: 200, rotation: 0, zIndex: 2, hidden: false, }, { id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence', x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false, diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 161cc42..223bb5b 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' import { Readable } from 'node:stream' import type { Pool, PoolClient } from 'pg' -import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js' +import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentCaptureKind, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js' import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js' import { clearBoard, cloneBoard } from './boardClone.js' import { loadCaseReport } from './caseReports.js' @@ -82,6 +82,7 @@ type LevelRow = { type ExhibitRow = { id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null + capture_kind_id: DocumentCaptureKind | null asset_id: string | null; published_at: Date | null; occurred_at: Date | null captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null original_name: string | null; mime_type: string | null; byte_size: string | null @@ -161,6 +162,10 @@ function documentType(document: CaseDocument): SourceFileType { const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file'] return allowed.includes(document.fileType) ? document.fileType : 'file' } +function documentCaptureKind(value: unknown): DocumentCaptureKind { + const allowed: DocumentCaptureKind[] = ['unclassified','photo','scene','clipping','full_page'] + return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified' +} export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage, evidenceJudge: EvidenceJudge): LevelRepository { async function findLevel(client: Pool | PoolClient, slug: string, lock = false) { const result = await client.query(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status, @@ -313,7 +318,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec pool.query(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden, COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') 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, d.captured_at, d.source_uri,d.citation_text,citation.display_number, + f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number, ev.occurred_at,claim.statement, p.party_kind, op.organization_kind, a.original_name, a.mime_type, a.byte_size, @@ -400,7 +405,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [], body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined, fileName: row.original_name || undefined, mimeType: row.mime_type || undefined, - fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} } + fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, + captureKind: documentCaptureKind(row.capture_kind_id), metadata: metadata.get(row.id) || {} } }) const evidence: Evidence[] = [] for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) { @@ -493,10 +499,11 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec } let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum) for (const document of documents) { - await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (exhibit_id) DO UPDATE SET + await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at, - captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, [document.id, documentType(document), document.assetId || null, document.title, + capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, + [document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, document.title, timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || '']) const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id]) if (!existingCitation.rows[0]) { @@ -818,7 +825,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec await client.query('COMMIT') return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false, displayNumber:citation.rows[0].display_number, - fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId, + fileType,captureKind:'unclassified',metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId, fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size, analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } } } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } diff --git a/server/levelVisibility.test.ts b/server/levelVisibility.test.ts index b9d4d0f..fe608ec 100644 --- a/server/levelVisibility.test.ts +++ b/server/levelVisibility.test.ts @@ -3,8 +3,8 @@ import type { CaseState, DocumentExhibit, NoteExhibit } from '../src/types.js' import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js' const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false } -const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', metadata: {}, ...placed } -const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed } +const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed } +const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed } const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed } const state: CaseState = { id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 }, diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index 5e7eef7..05759be 100644 --- a/server/migrations.integration.test.ts +++ b/server/migrations.integration.test.ts @@ -54,6 +54,7 @@ suite('PostgreSQL migrations', () => { 'level_goals', 'level_goal_flag_requirements', 'evidence_semantic_rules', 'evidence_semantic_evaluations', 'claim_exhibits', 'exhibit_citations', 'case_reports', 'case_report_submissions', 'case_report_submission_issues', + 'document_capture_kinds', ])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue'])) const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') diff --git a/src/App.tsx b/src/App.tsx index ef0b839..a568c0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, 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, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' +import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' +import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' -import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' +import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import type { PlaythroughState } from './narrative' const BOARD_W = 2400 @@ -20,7 +20,7 @@ function screenshotFile(file: File, index: number) { } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function documentSearchText(document: CaseDocument) { - return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType, + return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType, ...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]), ...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase() } @@ -54,6 +54,7 @@ export function App() { const [clock, setClock] = useState('') const [draggingFiles, setDraggingFiles] = useState(false) const [uploading, setUploading] = useState(0) + const [documentClassificationQueue, setDocumentClassificationQueue] = useState([]) const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move') const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) @@ -275,7 +276,10 @@ export function App() { setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') return } - setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) + const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom) + const target = caseState.exhibits.find(exhibit => exhibit.id === targetId) + setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, + label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) setLinkFrom(null) if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId) } @@ -421,12 +425,24 @@ export function App() { } else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED') else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE') else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`) + if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])]) } catch (error) { setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') } finally { setUploading(count => count - 1) } } }, [caseState, loadLevelBySlug, requestedEditMode, update]) + const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => { + const presentation=documentCapture(captureKind) + const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height }) + update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document' + ? classify(exhibit) + : exhibit) })) + setOpenDoc(current => current?.id === documentId ? classify(current) : current) + setDocumentClassificationQueue(current => current.filter(id => id !== documentId)) + setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`) + } + const continueAfterGoal = async () => { if (!activePlaythroughId) { setCompletedGoal(null); return } setAdvancing(true) @@ -501,6 +517,7 @@ export function App() { const documentById = new Map(documents.map(document => [document.id, document])) const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase() const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents + const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length const briefAttentionCount = unresolvedConceptCount + pendingGoalCount @@ -553,7 +570,7 @@ export function App() {
{filteredDocuments.map((doc, index) => )} {normalizedDocumentQuery && filteredDocuments.length === 0 &&
NO MATCHING DOCUMENTSSearches titles, contents, extracts, and metadata.
}
@@ -600,7 +617,11 @@ export function App() { `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/> {timelineView?.visible !== false && setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/> } - {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />} + {classificationDocument && classifyDocument(classificationDocument.id,captureKind)}/>} + {openDoc && setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)} + onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }} + onType={captureKind => classifyDocument(openDoc.id,captureKind)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />} {editingFolderId && widget.id === editingFolderId && widget.type === 'folder')!} @@ -621,7 +642,7 @@ export function App() { setStatus('FOLDER UPDATED') }} />} - {editingFileId && document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/> + {editingFileId && document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/> } {editingEventId && {definition.heading(ev, widgetContext)}{String(i + 1).padStart(3, '0')} })} - {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
!document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }} onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}> -
{definition.label.toUpperCase()}{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}
+
{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}
onUpdateDocumentCue(document.id, cue)}/>
{document.title}
@@ -1242,16 +1263,42 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E } +function CaptureKindIcon({ kind }:{ kind:Exclude }) { + if (kind === 'photo') return + if (kind === 'scene') return + if (kind === 'clipping') return + return +} + +function DocumentClassificationPanel({ document,onChoose }:{ document:CaseDocument;onChoose:(kind:DocumentCaptureKind)=>void }) { + const choices=(Object.keys(documentCaptureRegistry) as DocumentCaptureKind[]).filter((kind):kind is Exclude => kind !== 'unclassified') + const source=document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : '' + return
+
Classify new evidence
+
+
{source ? : }
EXHIBIT {document.displayNumber || '—'}{document.title}
+
NEW SOURCE DOCUMENT

What kind of evidence is this?

This changes how it appears on the board. It does not alter the original file, OCR, or provenance.

+
{choices.map(kind => { const definition=documentCapture(kind); return })}
+ +
+
+} + function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) { const [title, setTitle] = useState(document.title) const [fileType, setFileType] = useState(document.fileType) + const [captureKind, setCaptureKind] = useState(document.captureKind) const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt)) const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', ')) const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value }))) const submit = (event: React.FormEvent) => { event.preventDefault() const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined - onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, + const presentation=documentCapture(captureKind) + onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt, + ...(captureKind !== document.captureKind ? presentation.defaultSize : {}), requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) }) } @@ -1263,6 +1310,7 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas + {canEditGates && }
ADDITIONAL METADATAFREE-FORM KEY / VALUE FIELDS
@@ -1385,10 +1433,28 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo } -function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) { +const DOCUMENT_TYPE_MENU:Exclude[] = ['full_page','scene','photo','clipping'] + +function DocumentWindow({ doc, onClose, onInfo, onDelete, onType, onExtract, extracted }: { + doc: CaseDocument + onClose: () => void + onInfo: () => void + onDelete: () => void + onType: (captureKind:Exclude) => void + onExtract: (id: string) => void + extracted: (string | undefined)[] +}) { const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 }) const [minimized, setMinimized] = useState(false) + const [menu, setMenu] = useState<'file'|'type'|null>(null) const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null) + const menuRef = useRef(null) + useEffect(() => { + if (!menu) return + const closeMenu = (event:PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenu(null) } + document.addEventListener('pointerdown',closeMenu) + return () => document.removeEventListener('pointerdown',closeMenu) + },[menu]) const startDrag = (e: React.PointerEvent) => { if ((e.target as HTMLElement).closest('button')) return drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y } @@ -1396,8 +1462,19 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum } return
drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}>{doc.title}
- {!minimized && <> -
GLITCH UNIVERSITY ARCHIVE{documentWidget(doc.fileType).label}
{doc.assetId ? : doc.body.map((line, i) =>

{line}

)}{doc.regions.length > 0 &&
{doc.regions.map(r => )}
}
+ {!minimized && <> +
setMenu(null)}>
GLITCH UNIVERSITY ARCHIVE{doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label}
{doc.assetId ? : doc.body.map((line, i) =>

{line}

)}{doc.regions.length > 0 &&
{doc.regions.map(r => )}
}
ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}PROVENANCE LOCKED
}
} diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index bb7b989..db52b1f 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -169,7 +169,7 @@ describe('folder domain behavior', () => { }) it('retains a configured expanded file position', () => { - const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} } + const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} } expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 }) }) @@ -187,7 +187,7 @@ describe('folder domain behavior', () => { } const normalized = normalizeCase(legacy) expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false }) - expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} }) + expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', captureKind:'unclassified',metadata: {} }) expect(normalized.relations).toHaveLength(1) }) }) @@ -197,7 +197,7 @@ describe('exhibit disposal', () => { const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' } const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined } const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] } - const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} } + const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} } const discarded = discardExhibit({ ...state, exhibits: [folder, document, note, event, party], diff --git a/src/boardDomain.ts b/src/boardDomain.ts index 0dae209..fea0988 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -1,4 +1,4 @@ -import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types' +import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types' export interface BoardPoint { x: number; y: number } @@ -233,6 +233,11 @@ function sourceFileType(value: unknown, mimeType: unknown): SourceFileType { return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file' } +function documentCaptureKind(value: unknown): DocumentCaptureKind { + const allowed: DocumentCaptureKind[] = ['unclassified', 'photo', 'scene', 'clipping', 'full_page'] + return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified' +} + /** Normalizes current API state and upgrades disposable pre-registry browser caches. */ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState { const state = input as LegacyCaseState @@ -243,7 +248,9 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState { goals: state.goals || [], report: state.report, views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)], - exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })), + exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, + ...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}), + ...placement(exhibit as unknown as Record, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })), updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed, newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [], } @@ -264,6 +271,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState { fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined, fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize), fileType: sourceFileType(document.fileType, document.mimeType), + captureKind: documentCaptureKind(document.captureKind), metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record : {}, } as Exhibit)) const evidence: Exhibit[] = legacyEvidence.map((item, index) => { diff --git a/src/exhibitRegistry.test.ts b/src/exhibitRegistry.test.ts index c0201bc..de7ed94 100644 --- a/src/exhibitRegistry.test.ts +++ b/src/exhibitRegistry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { SourceFileType } from './types' -import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry' +import type { DocumentCaptureKind, DocumentExhibit, SourceFileType } from './types' +import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry' describe('frontend exhibit registry', () => { it('registers every normalized exhibit type', () => { @@ -18,4 +18,21 @@ describe('frontend exhibit registry', () => { expect(documentWidget(type).Asset).toBeTypeOf('function') } }) + + it('registers every capture kind with a physical board size', () => { + const kinds:DocumentCaptureKind[]=['unclassified','photo','scene','clipping','full_page'] + expect(Object.keys(documentCaptureRegistry).sort()).toEqual(kinds.sort()) + for (const kind of kinds) expect(documentCapture(kind).defaultSize).toMatchObject({ width:expect.any(Number),height:expect.any(Number) }) + expect(['full_page','scene','photo','clipping'].map(kind => documentCapture(kind as DocumentCaptureKind).label)).toEqual(['Document','Image','Mugshot','Clip']) + }) + + it('uses structural and image-aware starter copy instead of calling a Party proof', () => { + const placed={ x:0,y:0,width:100,height:100,rotation:0,zIndex:1,hidden:false } + const party={ id:'party',type:'party' as const,title:'Nils',content:'',partyKind:'person' as const,aliases:[],...placed } + const claim={ id:'claim',type:'claim' as const,title:'Inventor',statement:'Nils was an inventor.',...placed } + const photo:DocumentExhibit={ id:'photo',type:'document',title:'Portrait',body:[],regions:[],fileType:'image',captureKind:'photo',metadata:{},...placed } + expect(defaultConnectionLabel(party,claim)).toBe('Subject of claim') + expect(defaultConnectionLabel(photo,party)).toBe('Depicts…') + expect(defaultConnectionLabel(photo,claim)).toBe('Proof that…') + }) }) diff --git a/src/exhibitRegistry.tsx b/src/exhibitRegistry.tsx index b862fa0..496edfd 100644 --- a/src/exhibitRegistry.tsx +++ b/src/exhibitRegistry.tsx @@ -1,6 +1,6 @@ import type { ComponentType } from 'react' import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react' -import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types' +import type { CaseDocument, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types' export type WidgetCommand = | { type: 'open-document'; documentId: string } @@ -135,5 +135,32 @@ export const documentWidgetRegistry:Record = { + unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} }, + photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:240} }, + scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} }, + clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} }, + full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} }, +} +export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified } + +/** Context-sensitive starter copy. A Party is never treated as proof by default. */ +export function defaultConnectionLabel(first:Exhibit,second:Exhibit) { + const types = new Set([first.type,second.type]) + if (types.has('party') && types.has('claim')) return 'Subject of claim' + const document = first.type === 'document' ? first : second.type === 'document' ? second : null + if (document && types.has('party')) { + if (document.captureKind === 'photo') return 'Depicts…' + if (document.captureKind === 'scene') return 'Shows…' + return 'Concerns…' + } + return 'Proof that…' +} + export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') } export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') } diff --git a/src/styles.css b/src/styles.css index 232801b..2c1ba15 100644 --- a/src/styles.css +++ b/src/styles.css @@ -169,6 +169,63 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; } .source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; } .source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; } + +/* Classified image evidence is rendered like a physical object on the corkboard. + The unclassified shell above remains the neutral Win-95 archive card. */ +.source-file-widget.capture-kind-photo, +.source-file-widget.capture-kind-scene, +.source-file-widget.capture-kind-clipping, +.source-file-widget.capture-kind-full_page { box-sizing: border-box; min-height: 0; background: #eee8d8; border: 0; box-shadow: 7px 9px 4px #0209078c, 0 0 0 1px #fff9; } +.source-file-widget.capture-kind-photo header, +.source-file-widget.capture-kind-scene header, +.source-file-widget.capture-kind-clipping header, +.source-file-widget.capture-kind-full_page header { position: absolute; z-index: 2; top: 10px; right: 10px; width: auto; height: auto; border: 0; color: #f1ead5; text-shadow: 0 1px 2px #000; pointer-events: none; } +.source-file-widget.capture-kind-photo header span, +.source-file-widget.capture-kind-scene header span, +.source-file-widget.capture-kind-clipping header span, +.source-file-widget.capture-kind-full_page header span { display: none; } +.source-file-widget.capture-kind-photo header i, +.source-file-widget.capture-kind-scene header i, +.source-file-widget.capture-kind-clipping header i, +.source-file-widget.capture-kind-full_page header i { padding: 3px 4px; background: #17231da8; border: 1px solid #efe8d066; font-style: normal; } +.source-file-widget.capture-kind-photo .source-file-preview, +.source-file-widget.capture-kind-scene .source-file-preview, +.source-file-widget.capture-kind-clipping .source-file-preview, +.source-file-widget.capture-kind-full_page .source-file-preview { margin: 0 0 8px; background: #d8d3c4; border: 1px solid #f9f6eb; box-shadow: inset 0 0 0 1px #5f625d; } +.source-file-widget.capture-kind-photo > strong, +.source-file-widget.capture-kind-scene > strong, +.source-file-widget.capture-kind-clipping > strong, +.source-file-widget.capture-kind-full_page > strong { color: #252720; font: 12px/1.15 Special Elite; } +.source-file-widget.capture-kind-photo .source-file-actions, +.source-file-widget.capture-kind-scene .source-file-actions, +.source-file-widget.capture-kind-clipping .source-file-actions, +.source-file-widget.capture-kind-full_page .source-file-actions { margin-top: 5px; border-color: #a49d8d; opacity: .42; transition: opacity .16s ease; } +.source-file-widget.capture-kind-photo:hover .source-file-actions, +.source-file-widget.capture-kind-scene:hover .source-file-actions, +.source-file-widget.capture-kind-clipping:hover .source-file-actions, +.source-file-widget.capture-kind-full_page:hover .source-file-actions, +.source-file-widget.selected .source-file-actions { opacity: 1; } +.source-file-widget.capture-kind-photo { padding: 10px 10px 12px; transform-origin: 50% 15%; } +.source-file-widget.capture-kind-photo.open { transform: scale(1) rotate(-1.25deg); } +.source-file-widget.capture-kind-photo .source-file-preview { height: 151px; } +.source-file-widget.capture-kind-photo .source-file-preview img { object-fit: cover; filter: saturate(.82) contrast(1.04) sepia(.08); } +.source-file-widget.capture-kind-photo > strong { padding: 1px 4px 0; text-align: center; font: 15px/1.1 "Marker Felt", "Comic Sans MS", cursive; transform: rotate(-.5deg); } +.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; } +.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; } +.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); } +.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; } +.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); } +.source-file-widget.capture-kind-scene > strong { font-size: 11px; } +.source-file-widget.capture-kind-clipping { padding: 9px 12px 11px; background: linear-gradient(103deg,#e7e2d3,#d9d2bf); clip-path: polygon(1% 2%,99% 0,98% 13%,100% 28%,98% 43%,100% 61%,98% 78%,99% 98%,84% 99%,68% 97%,52% 100%,35% 98%,19% 100%,1% 98%,2% 79%,0 62%,2% 45%,0 27%); } +.source-file-widget.capture-kind-clipping.open { transform: scale(1) rotate(.8deg); } +.source-file-widget.capture-kind-clipping .source-file-preview { height: 105px; border-color: #b2a996; box-shadow: none; } +.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit: cover; filter: grayscale(.12) contrast(1.08); } +.source-file-widget.capture-kind-clipping > strong { font-size: 11px; } +.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; } +.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); } +.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; } +.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; } +.source-file-widget.capture-kind-full_page > strong { font-size: 10px; } .evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; } .evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; } .evidence-card.note header i { display: none; } @@ -237,7 +294,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; } .window > header { position: sticky; z-index: 3; top: 0; height: 31px; min-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; } +.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); } +.document-window > nav { position: sticky; z-index: 2; top: 31px; height: 28px; display: flex; align-items: stretch; gap: 2px; padding: 0 7px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; } +.document-menu { position: relative; display: flex; }.document-menu > button { min-width: 48px; padding: 0 8px; border: 0; background: transparent; color: #24312d; cursor: pointer; font: 9px IBM Plex Mono; letter-spacing: .05em; }.document-menu > button:hover,.document-menu > button[aria-expanded=true] { background: #173e35; color: #f0f3ee; } +.document-menu-items { position: absolute; z-index: 6; top: 27px; left: 0; width: 250px; display: grid; gap: 2px; padding: 4px; background: #c6cac3; border: 2px outset #edf0e9; box-shadow: 5px 7px 0 #07100dcc; }.document-menu-items > button { position: relative; min-height: 43px; display: grid; grid-template-columns: 25px minmax(0,1fr) 16px; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid transparent; background: transparent; color: #26342f; text-align: left; cursor: pointer; }.document-menu-items > button:hover,.document-menu-items > button:focus-visible { outline: 0; border-color: #557067; background: #173e35; color: #f4f6f1; }.document-menu-items > button > span { min-width: 0; display: grid; gap: 2px; }.document-menu-items b { font: 600 9px IBM Plex Mono; letter-spacing: .05em; }.document-menu-items small { color: #65716c; font: 7px IBM Plex Mono; }.document-menu-items > button:hover small,.document-menu-items > button:focus-visible small { color: #b9c9c2; }.document-menu-items > button.danger { color: #782f2b; }.document-menu-items > button.danger:hover,.document-menu-items > button.danger:focus-visible { background: #702e2b; color: white; }.document-menu-items .menu-check { color: #955b2f; }.document-menu-items > button:hover .menu-check { color: #efb06c; }.type-menu { width: 286px; } .paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; } .paper.asset-paper { padding: 20px; display: flex; flex-direction: column; }.asset-paper .paper-meta { flex: 0 0 auto; margin-bottom: 14px; }.document-image { display: block; max-width: 100%; margin: auto; box-shadow: 0 2px 12px #0005; }.document-frame { width: 100%; flex: 1; min-height: 350px; border: 1px solid #81877f; background: white; }.unsupported-file { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; color: #4a5651; }.unsupported-file b { font-size: 12px; }.unsupported-file span { font-size: 9px; color: #758079; }.unsupported-file a { margin-top: 9px; padding: 8px 11px; background: #244b40; color: white; text-decoration: none; font: 9px IBM Plex Mono; } .paper-meta { display: flex; justify-content: space-between; font-size: 8px; letter-spacing: .1em; border-bottom: 2px solid #252d29; padding-bottom: 9px; margin-bottom: 28px; }.paper-meta b { color: #945b32; } @@ -246,6 +306,18 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .extracts button { border: 1px solid #9b602f; background: #f0dbc0; color: #66391a; padding: 8px 10px; display: flex; align-items: center; gap: 6px; cursor: pointer; font: 600 9px IBM Plex Mono; text-transform: uppercase; }.extracts button:hover { background: #e8bd87; }.extracts button.done { border-color: #667a71; background: #d1d7ce; color: #42554d; } .document-window > footer { height: 25px; border-top: 1px solid #737f78; display: flex; justify-content: space-between; padding: 6px 8px; font: 8px IBM Plex Mono; } .modal-shade { position: fixed; z-index: 40; inset: 0; background: #020b09aa; display: grid; place-items: center; } +.evidence-classification-shade { z-index: 80; padding: 18px; backdrop-filter: blur(2px); } +.evidence-classification { width: min(680px,calc(100vw - 36px)); max-height: calc(100dvh - 36px); overflow: auto; color: #1d2824; background: #c8ccc4; border: 2px solid #dfe2dc; box-shadow: 8px 10px 0 #020907,0 0 0 1px #46544f; animation: evidence-classification-in .25s cubic-bezier(.2,.8,.2,1) both; } +.evidence-classification > header { position: sticky; z-index: 2; top: 0; height: 33px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 10px; color: #e5ece8; background: #183f36; font: 500 10px IBM Plex Mono; text-transform: uppercase; } +.evidence-classification > header span { flex: 1; }.evidence-classification > header button { width: 23px;height: 22px;display:grid;place-items:center;padding:0;color:#17221f;background:#bcc1b9;border:1px outset white;cursor:pointer; } +.evidence-classification-body { padding: 24px 27px 25px; } +.classification-source { min-height: 92px; display: grid; grid-template-columns: 122px minmax(0,1fr); gap: 15px; align-items: center; padding: 9px; border: 1px solid #8a928c; background: #b7bbb3; } +.classification-source > img { width: 122px;height:78px;object-fit:cover;border:5px solid #eee9db;box-shadow:3px 4px #0004;transform:rotate(-1deg); }.classification-source > svg { margin:auto;color:#5e6d67; } +.classification-source > div { min-width:0;display:grid;gap:6px; }.classification-source small { color:#7d502d;font:600 7px IBM Plex Mono;letter-spacing:.14em; }.classification-source strong { overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:16px Special Elite; } +.classification-question { margin: 22px 0 14px; }.classification-question > small { color:#8a542d;font:600 7px IBM Plex Mono;letter-spacing:.17em; }.classification-question h2 { margin:6px 0 7px;font:25px Special Elite; }.classification-question p { margin:0;color:#59645f;font:9px/1.5 IBM Plex Mono; } +.classification-options { display:grid;grid-template-columns:1fr 1fr;gap:9px; }.classification-options > button { min-height:78px;display:grid;grid-template-columns:34px 1fr;gap:9px;align-items:center;padding:12px;text-align:left;color:#253730;background:#d9dbd3;border:1px solid #8e9690;cursor:pointer; }.classification-options > button:hover,.classification-options > button:focus-visible { outline:0;border-color:#9b6035;background:#e5d6bd;box-shadow:inset 4px 0 #9b6035; }.classification-options svg { color:#8b572f; }.classification-options span { display:grid;gap:5px; }.classification-options b { font:600 10px IBM Plex Mono;text-transform:uppercase; }.classification-options small { color:#64706a;font:8px/1.4 IBM Plex Mono; } +.classify-later { width:100%;margin-top:11px;padding:9px;border:1px dashed #8a928c;color:#65706b;background:transparent;cursor:pointer;font:7px IBM Plex Mono;letter-spacing:.11em; }.classify-later:hover { color:#384b44;background:#d4d6ce; } +@keyframes evidence-classification-in { from { opacity:0;transform:translateY(18px) scale(.96); } } .help { width: 440px; }.help > div { padding: 30px 34px 34px; }.help h2 { font: 23px Special Elite; margin: 8px 0 22px; }.help ol { padding-left: 22px; font-size: 12px; line-height: 2; }.help p { font: 13px Special Elite; border-left: 3px solid #a56330; padding-left: 12px; }.primary { float: right; background: #163f35; color: white; border: 2px outset #608177; font: 9px IBM Plex Mono; padding: 10px 13px; cursor: pointer; } .folder-editor { width: min(680px, 88vw); } .folder-editor-body { padding: 24px 27px 22px; } @@ -336,6 +408,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } .match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; } .case-report-shade { padding:0; }.case-report { width:100vw;height:100dvh;border:0; }.case-report-paper { padding:28px 17px 24px; }.report-fields { grid-template-columns:1fr;gap:0; }.report-evidence-heading { grid-template-columns:auto 1fr; }.report-evidence-heading em { grid-column:1/-1;width:max-content; }.case-report-actions { bottom:-24px;margin-left:-7px;margin-right:-7px; } + .evidence-classification-shade { padding:0; }.evidence-classification { width:100vw;max-width:none;max-height:100dvh;border-width:0;box-shadow:none; }.evidence-classification-body { padding:18px 16px 22px; }.classification-options { grid-template-columns:1fr; }.classification-source { grid-template-columns:92px minmax(0,1fr); }.classification-source > img { width:92px;height:66px; }.classification-question h2 { font-size:22px; } .board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; } .board-actions > b { display: none; } .board-actions > span { margin: 0 2px; } diff --git a/src/types.ts b/src/types.ts index 29d2285..d968b79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ export type ExhibitType = 'folder' | 'document' | '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 type DocumentCaptureKind = 'unclassified' | 'photo' | 'scene' | 'clipping' | 'full_page' export interface CanvasPlacement { x: number @@ -49,6 +50,8 @@ export interface DocumentExhibit extends ExhibitBase { mimeType?: string fileSize?: number fileType: SourceFileType + /** Evidentiary form on the board; independent of MIME/file type. */ + captureKind: DocumentCaptureKind metadata: Record }