From 57c8c91f21abe6e6c2d42cab207f52b8adfea35c Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 18:11:51 +0200 Subject: [PATCH] fix: connect source documents with red thread --- e2e/mystery.acceptance.spec.ts | 30 ++++++++++++++++++++++++++++-- src/App.tsx | 6 +++--- src/styles.css | 3 +++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index 9e91888..88f4f34 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -30,6 +30,10 @@ async function createEvent(page: Page, title: string, narrative: string, occurre test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => { test.setTimeout(90_000) + await expect.poll(async () => { + const seededLevels = await (await request.get('/api/levels')).json() as { id: string }[] + return seededLevels.some(level => level.id.startsWith('glass-harbor-case-')) + }).toBe(true) const levels = await (await request.get('/api/levels')).json() as { id: string }[] const playable = levels.find(level => level.id.startsWith('glass-harbor-case-')) if (!playable) throw new Error('Glass Harbor playable clone was not seeded') @@ -96,14 +100,35 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click()) await expect(page.locator('.connections g.tightening path')).toBeVisible() + const procurement = page.locator('.evidence-card.folder').filter({ hasText: 'PROCUREMENT & OWNERSHIP' }) + await waitForSave(page, () => procurement.getByRole('button', { name: 'OPEN', exact: true }).click()) + const companyRegister = page.locator('.source-file-widget.open').filter({ hasText: 'Company Register Extract' }) + const memorandum = page.locator('.source-file-widget.open').filter({ hasText: 'Delivery Redirection Memorandum' }) + const photograph = page.locator('.source-file-widget.open').filter({ hasText: 'Warehouse 3 Security Photograph' }) + await companyRegister.click() + await expect(companyRegister).toHaveClass(/\bselected\b/) + await page.getByRole('button', { name: 'Red thread', exact: true }).click() + await expect(companyRegister).toHaveClass(/\blinking\b/) + await memorandum.click() + await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible() + await expect(threadEditor.getByText('Delivery Redirection Memorandum', { exact: true })).toBeVisible() + await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3') + await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click()) + await page.getByRole('button', { name: 'Red thread', exact: true }).click() + await photograph.click() + await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible() + await expect(threadEditor.getByText('Warehouse 3 Security Photograph', { exact: true })).toBeVisible() + await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3') + await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click()) + await page.reload() await expect(page.locator('.evidence-card.party')).toHaveCount(6) await expect(page.locator('.evidence-card.event')).toHaveCount(3) await expect(page.locator('.evidence-card.note')).toHaveCount(1) await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3') await expect(page.locator('.event-support-lines line')).toHaveCount(6) - await expect(page.locator('.connections path')).toHaveCount(1) - const relationTag = page.locator('.thread-tag') + await expect(page.locator('.connections path')).toHaveCount(3) + const relationTag = page.locator('.thread-tag').filter({ hasText: 'Proof Elias is the driver' }) await expect(relationTag).toContainText('Proof Elias is the driver') await relationTag.click() await expect(relationTag).toHaveClass(/\bexpanded\b/) @@ -120,6 +145,7 @@ test('a cloned Glass Harbor level can be solved without modifying its template', const solved = await (await request.get(`/api/levels/${playable.id}`)).json() expect(solved.connections).toContainEqual(expect.objectContaining({ label: 'Proof Elias is the driver', tightness: 85, tagStyle: 'compact' })) + expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2) const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[] expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 })) diff --git a/src/App.tsx b/src/App.tsx index 095c962..b221926 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -570,9 +570,9 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
})} - {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }} - onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (open && linkFrom) onConnectionTarget(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}> + 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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
{document.title} @@ -622,7 +622,7 @@ function Timeline({ items, range, selected, onSelect, onEdit }: { items: Tempora 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
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
+ return
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
} function localDateTime(value?: string) { diff --git a/src/styles.css b/src/styles.css index 29fe5b8..ebc617e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -69,6 +69,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .luggage-tag.luggage-tag::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; } .luggage-tag.luggage-tag::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -1px; height: 4px; background: #745a37; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); } .thread-tag { position: absolute; z-index: 7; border: 0; cursor: pointer; } +.board-viewport.threading .thread-tag { pointer-events: none; opacity: .72; } .thread-tag.untagged { width: 13px; height: 13px; padding: 0; transform: translate(-50%, -50%); border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; } .thread-tag.untagged i { display: none; } .thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; } @@ -132,6 +133,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); } .source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; } .source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); } +.source-file-widget.selected { outline: 2px solid #e49a4a; outline-offset: 5px; } +.source-file-widget.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; } .source-file-widget header { height: 18px; display: flex; justify-content: space-between; border-bottom: 1px solid #8b938d; color: #59645f; font: 600 7px IBM Plex Mono; letter-spacing: .1em; } .source-file-preview { height: 79px; margin: 7px 0; display: grid; place-items: center; overflow: hidden; background: #263a35; border: 1px solid #707a74; } .source-file-preview img { width: 100%; height: 100%; object-fit: cover; }