feat: add configurable timeline range

This commit is contained in:
2026-08-14 15:40:54 +02:00
parent b284275e98
commit 45c7c6a292
15 changed files with 121 additions and 14 deletions
+2
View File
@@ -103,6 +103,8 @@ Folder ownership is stored as a normalized membership. The contained document ex
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement. An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
The timeline uses an optional board-level start and end date. Click **Timeline** in the menu or the displayed range in the footer to adjust it; **Use Automatic Range** returns to evidence-derived bounds. Configured bounds are normalized board data and follow template cloning and reset. Evidence outside the visible interval remains available and is pinned to the nearest timeline edge.
The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data. The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data.
## Deploy at osint.glitch.university ## Deploy at osint.glitch.university
+1
View File
@@ -9,6 +9,7 @@ Status: accepted design foundation; the core schema, template lifecycle, fronten
- A **widget** is the frontend visualization and interaction implementation selected for an exhibit type. - 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 **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file.
- A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards. - 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. - A **level** is a mutable board copy used for either play or authoring.
- A **level template version** is an immutable board snapshot. - A **level template version** is an immutable board snapshot.
+12
View File
@@ -33,6 +33,18 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible() await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
await expect(page.locator('.evidence-card.folder')).toHaveCount(3) await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
await expect(page.locator('.timeline .marker')).toHaveCount(8) await expect(page.locator('.timeline .marker')).toHaveCount(8)
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31')
const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox()
const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox()
expect(dispatchMarker).not.toBeNull()
expect(auctionMarker).not.toBeNull()
expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100)
await page.getByRole('button', { name: 'TIMELINE', exact: true }).click()
await page.getByLabel('Timeline start date').fill('1987-10-10')
await page.getByLabel('Timeline end date').fill('1987-10-26')
await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click())
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-10 — 1987-10-26')
await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.') await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.')
await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.') await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.')
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE osint.board_timeline_settings (
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
range_start DATE NOT NULL,
range_end DATE NOT NULL,
CHECK (range_end > range_start)
);
COMMENT ON TABLE osint.board_timeline_settings IS 'Optional authored temporal viewport. Absence means derive the timeline range from dated exhibits.';
+1
View File
@@ -3,6 +3,7 @@
"name": "The Glass Harbor Diversion", "name": "The Glass Harbor Diversion",
"title": "The Glass Harbor Diversion", "title": "The Glass Harbor Diversion",
"subtitle": "Greyhaven file 87-10 · missing lighthouse optics", "subtitle": "Greyhaven file 87-10 · missing lighthouse optics",
"timelineRange": { "start": "1987-10-01", "end": "1987-10-31" },
"brief": { "brief": {
"body": "A replacement Fresnel lens purchased for North Quay Lighthouse vanished between dispatch and installation. Determine who arranged the diversion, which organization stood to benefit, and when the shipment changed course. Classify every named concept, associate each party with the evidence that supports its dossier, reconstruct the decisive events, and make your conclusion visible with red thread. The terminal will not announce a winner: a solved board is a defensible account of what happened.", "body": "A replacement Fresnel lens purchased for North Quay Lighthouse vanished between dispatch and installation. Determine who arranged the diversion, which organization stood to benefit, and when the shipment changed course. Classify every named concept, associate each party with the evidence that supports its dossier, reconstruct the decisive events, and make your conclusion visible with red thread. The terminal will not announce a winner: a solved board is a defensible account of what happened.",
"concepts": [ "concepts": [
+2
View File
@@ -18,6 +18,7 @@ type MysteryManifest = {
name: string name: string
title: string title: string
subtitle: string subtitle: string
timelineRange?: { start: string; end: string }
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] } brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
documents: MysteryDocument[] documents: MysteryDocument[]
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[] folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
@@ -66,6 +67,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()])) const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) } state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
state.timelineRange = manifest.timelineRange
state.documents = [...documents.values()] state.documents = [...documents.values()]
state.evidence = manifest.folders.map(folder => ({ state.evidence = manifest.folders.map(folder => ({
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
+11
View File
@@ -68,6 +68,7 @@ suite('level persistence API', () => {
expect(createResponse.status).toBe(201) expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState const state = await createResponse.json() as CaseState
state.viewport = { x: 91, y: -42, zoom: 0.85 } state.viewport = { x: 91, y: -42, zoom: 0.85 }
state.timelineRange = { start: '2021-04-01', end: '2021-04-30' }
const documentId = randomUUID() const documentId = randomUUID()
const folderId = randomUUID() const folderId = randomUUID()
const noteId = randomUUID() const noteId = randomUUID()
@@ -96,12 +97,20 @@ suite('level persistence API', () => {
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport) expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.timelineRange).toEqual(state.timelineRange)
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) 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.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } })
expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ expect(loaded.brief.concepts).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }),
])) ]))
const legacyClientState = structuredClone(loaded)
delete legacyClientState.timelineRange
const legacySave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(legacyClientState),
})
expect(legacySave.ok).toBe(true)
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).timelineRange).toEqual(state.timelineRange)
const upload = new FormData() const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
@@ -177,6 +186,7 @@ suite('level persistence API', () => {
expect(resetResponse.ok).toBe(true) expect(resetResponse.ok).toBe(true)
const resetState = await resetResponse.json() as CaseState const resetState = await resetResponse.json() as CaseState
expect(resetState.viewport).toEqual(playerState.viewport) expect(resetState.viewport).toEqual(playerState.viewport)
expect(resetState.timelineRange).toEqual(state.timelineRange)
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const templateResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { const templateResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
@@ -200,6 +210,7 @@ suite('level persistence API', () => {
expect(cloneResponse.status).toBe(201) expect(cloneResponse.status).toBe(201)
const clone = await cloneResponse.json() as CaseState const clone = await cloneResponse.json() as CaseState
expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) }) expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) })
expect(clone.timelineRange).toEqual(state.timelineRange)
expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 }) expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId) expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId)
expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id) expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id)
+7
View File
@@ -10,6 +10,7 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
} }
export async function clearBoard(client: PoolClient, boardId: string) { export async function clearBoard(client: PoolClient, boardId: string) {
await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId]) 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.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.metadata_fields WHERE board_id=$1', [boardId])
@@ -23,6 +24,12 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
const regionIds: IdMap = new Map() const regionIds: IdMap = new Map()
const fieldIds: IdMap = new Map() const fieldIds: IdMap = new Map()
const timeline = await client.query<{ range_start: string; range_end: string }>(
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId])
if (timeline.rows[0]) await client.query(
'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
[targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end])
const exhibits = await client.query<{ const exhibits = await client.query<{
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
rotation: number; z_index: number; hidden: boolean rotation: number; z_index: number; hidden: boolean
+18 -1
View File
@@ -64,7 +64,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
const level = await findLevel(pool, slug) const level = await findLevel(pool, slug)
if (!level) return null if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult] = await Promise.all([ aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden, 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, p.display_name, '') AS title, 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, COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
@@ -111,6 +111,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>( 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 `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]), WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
pool.query<{ range_start: string; range_end: string }>(
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]),
]) ])
const blocks = new Map<string, string[]>() const blocks = new Map<string, string[]>()
@@ -155,6 +157,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations, return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })), connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled,
sourceTemplateVersionId: level.source_template_version_id || undefined } sourceTemplateVersionId: level.source_template_version_id || undefined }
} }
@@ -189,6 +192,11 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos }) 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 }>( 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])) '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]))
const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>(
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
const existingTimeline = existingTimelineResult.rows[0]
? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end }
: null
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6, 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]) updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
@@ -198,6 +206,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query('DELETE FROM osint.event_evidence 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_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.party_relationships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.board_timeline_settings 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.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.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.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
@@ -297,6 +306,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
[document.id, fields.get(key), document.metadata[key]]) [document.id, fields.get(key), document.metadata[key]])
} }
const brief = state.brief || { body: '', concepts: [] } const brief = state.brief || { body: '', concepts: [] }
const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange
if (savedTimeline) {
const start = timestamp(savedTimeline.start)
const end = timestamp(savedTimeline.end)
if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start')
await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
[level.board_id, savedTimeline.start, savedTimeline.end])
}
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || '']) 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()) { for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id') requireUuid(concept.id, 'Brief concept id')
+4 -3
View File
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(7) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(8)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
@@ -43,15 +43,16 @@ suite('PostgreSQL migrations', () => {
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits', 'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations', 'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs', 'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
'board_timeline_settings',
])) ]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs'])) 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') const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('7') expect(ledger.rows[0].count).toBe('8')
await client.end() await client.end()
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(7) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(8)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+34 -7
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' import { 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 type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain' import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry' import { documentWidget, exhibitWidget } from './exhibitRegistry'
@@ -36,6 +36,7 @@ export function App() {
const [editingPartyId, setEditingPartyId] = useState<string | null>(null) const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [briefOpen, setBriefOpen] = useState(false) const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false) const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false)
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null) const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -234,7 +235,7 @@ export function App() {
<header className="menubar"> <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> <div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav> <nav>
<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> <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={() => setEditingTimeline(true)}>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> </nav>
<div className="terminal-status"><i /> {status}<span>{clock}</span></div> <div className="terminal-status"><i /> {status}<span>{clock}</span></div>
</header> </header>
@@ -285,7 +286,7 @@ export function App() {
</section> </section>
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> <TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
<Timeline items={temporalItems} selected={selected} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> <Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} {openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>} {editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} {editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
@@ -318,6 +319,16 @@ export function App() {
setStatus('LEVEL BRIEF UPDATED') setStatus('LEVEL BRIEF UPDATED')
}} }}
/>} />}
{editingTimeline && <TimelineRangeEditor
range={caseState.timelineRange}
dates={temporalItems.map(item => item.date)}
onClose={() => setEditingTimeline(false)}
onSave={timelineRange => {
update(state => ({ ...state, timelineRange }))
setEditingTimeline(false)
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
}}
/>}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>} {helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
</main> </main>
} }
@@ -464,11 +475,13 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey:
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg> return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
} }
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) { function Timeline({ items, range, selected, onSelect, onEdit }: { items: TemporalItem[]; range?: TimelineRange | null; selected: string | null; onSelect: (item: TemporalItem) => void; onEdit: () => void }) {
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date)) const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date), undefined, range || undefined)
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
const position = (date: string) => timelinePositionPercent(date, { start, end }) const position = (date: string) => timelinePositionPercent(date, { start, end })
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer> const ticks = range
? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
: Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
} }
function localDateTime(value?: string) { function localDateTime(value?: string) {
@@ -480,6 +493,20 @@ function localDateTime(value?: string) {
return local.toISOString().slice(0, 16) return local.toISOString().slice(0, 16)
} }
function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
const [start, setStart] = useState(range?.start || dated[0] || '')
const [end, setEnd] = useState(range?.end || dated[dated.length - 1] || '')
const valid = Boolean(start && end && end > start)
return <div className="modal-shade"><form className="window timeline-editor" onSubmit={event => { event.preventDefault(); if (valid) onSave({ start, end }) }}>
<header><CalendarClock size={16}/><b>Adjust timeline range</b><span/><button type="button" aria-label="Close timeline editor" onClick={onClose}><X size={14}/></button></header>
<div><small>TEMPORAL VIEWPORT · BOARD SETTING</small><p>Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.</p>
<div className="timeline-range-fields"><label className="field"><span>START DATE</span><input aria-label="Timeline start date" type="date" required value={start} onChange={event => setStart(event.target.value)}/></label><label className="field"><span>END DATE</span><input aria-label="Timeline end date" type="date" required value={end} min={start} onChange={event => setEnd(event.target.value)}/></label></div>
<div className="folder-editor-actions"><button type="button" onClick={() => onSave(null)}>USE AUTOMATIC RANGE</button><button className="primary" type="submit" disabled={!valid}>APPLY RANGE</button></div>
</div>
</form></div>
}
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 }) { 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])) 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> 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>
+8
View File
@@ -90,6 +90,14 @@ describe('timeline projection', () => {
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49) expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51) expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
}) })
it('uses a valid configured date range without automatic year padding', () => {
const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
})
}) })
describe('folder domain behavior', () => { describe('folder domain behavior', () => {
+9 -2
View File
@@ -1,4 +1,4 @@
import type { CaseState, Evidence, Viewport, WidgetRelation } from './types' import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
export const MIN_BOARD_ZOOM = 0.45 export const MIN_BOARD_ZOOM = 0.45
export const MAX_BOARD_ZOOM = 1.5 export const MAX_BOARD_ZOOM = 1.5
@@ -48,7 +48,14 @@ export function dateValue(date: string) {
return Number.isFinite(parsed) ? parsed : 0 return Number.isFinite(parsed) ? parsed : 0
} }
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear()) { export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) {
if (configured) {
const start = Date.parse(`${configured.start}T00:00:00.000Z`)
const end = Date.parse(`${configured.end}T23:59:59.999Z`)
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end }
}
}
const years = dates const years = dates
.map(date => Number(date.slice(0, 4))) .map(date => Number(date.slice(0, 4)))
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999) .filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
+2 -1
View File
@@ -143,7 +143,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.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; } .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; } .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; }
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; } .timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; }
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label span { font: 8px IBM Plex Mono; color: #a07142; } .timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 0; overflow: hidden; color: #a07142; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 8px IBM Plex Mono; }
.timeline-track { height: 75px; margin: 0 43px; position: relative; } .timeline-track { height: 75px; margin: 0 43px; position: relative; }
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; } .axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
.year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; } .year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; }
@@ -154,6 +154,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.marker.selected i { border-color: #eea458; background: #eea458; } .marker.selected i { border-color: #eea458; background: #eea458; }
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; } .timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; } .window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
.timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; } .window > header { 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; } .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 { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
+2
View File
@@ -63,6 +63,7 @@ export interface Connection {
} }
export interface Viewport { x: number; y: number; zoom: number } export interface Viewport { x: number; y: number; zoom: number }
export interface TimelineRange { start: string; end: string }
export interface BriefConcept { export interface BriefConcept {
id: string id: string
@@ -83,6 +84,7 @@ export interface CaseState {
relations: WidgetRelation[] relations: WidgetRelation[]
connections: Connection[] connections: Connection[]
viewport: Viewport viewport: Viewport
timelineRange?: TimelineRange | null
brief: LevelBrief brief: LevelBrief
updatedAt?: string updatedAt?: string
levelStatus?: string levelStatus?: string