refactor: reuse luggage tags for thread labels
This commit is contained in:
@@ -99,7 +99,7 @@ The current POC defines four exhibit families and corresponding frontend widgets
|
||||
|
||||
The **Party** family has distinct Person and Organization subtypes (businesses are organizations). Names begin as concepts in the level brief. The investigator classifies each concept, which creates the appropriate dossier exhibit and records the resolution. Expected classifications remain author-only. Dossiers store aliases and normalized evidence associations, but parties remain identity objects rather than special folders.
|
||||
|
||||
**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag and a persisted tightness percentage. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again.
|
||||
**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag and a persisted tightness percentage. Relation labels reuse the investigator note's luggage-tag primitive: they hang from the thread, rotate into a readable position on first click, and open the thread editor on the next click. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again.
|
||||
|
||||
Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders.
|
||||
|
||||
|
||||
@@ -94,7 +94,14 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
|
||||
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
|
||||
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
|
||||
await expect(page.locator('.connections path')).toHaveCount(1)
|
||||
await expect(page.locator('.thread-tag')).toContainText('Proof Elias is the driver')
|
||||
const relationTag = page.locator('.thread-tag')
|
||||
await expect(relationTag).toContainText('Proof Elias is the driver')
|
||||
await relationTag.click()
|
||||
await expect(relationTag).toHaveClass(/\bexpanded\b/)
|
||||
await expect(relationTag).toHaveAttribute('aria-expanded', 'true')
|
||||
await relationTag.click()
|
||||
await expect(page.getByText('Edit red thread')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Close thread editor', exact: true }).click()
|
||||
|
||||
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 }))
|
||||
|
||||
+4
-3
@@ -422,6 +422,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||
const pinchDistance = useRef<number | null>(null)
|
||||
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
||||
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
|
||||
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
||||
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
||||
const pointForId = (id: string) => {
|
||||
@@ -508,7 +509,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
||||
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
||||
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
|
||||
onPointerDown={e => { if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
|
||||
onPointerDown={e => { if (!(e.target as HTMLElement).closest('.thread-tag')) setExpandedThreadTagId(null); if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
|
||||
onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
|
||||
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
|
||||
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
|
||||
@@ -516,7 +517,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
|
||||
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
|
||||
</svg>
|
||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; return <button key={`tag:${connection.id}`} className={`thread-tag ${connection.label ? 'labelled' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title="Edit thread tag and tightness" onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); onEditConnection(connection) }}><i/>{connection.label && <span>{connection.label}</span>}</button> })}
|
||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; const expanded = expandedThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? 'labelled luggage-tag' : 'untagged'} ${expanded ? 'expanded' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title={connection.label ? expanded ? 'Click again to edit this thread' : 'Rotate relation tag to read' : 'Edit thread tag and tightness'} onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); if (!connection.label || expanded) onEditConnection(connection); else setExpandedThreadTagId(connection.id) }}><i/>{connection.label && <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>}</button> })}
|
||||
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
||||
const evidence = byId.get(evidenceId)
|
||||
@@ -548,7 +549,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
|
||||
</svg>
|
||||
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
|
||||
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
|
||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
|
||||
|
||||
+18
-8
@@ -58,10 +58,22 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.connections .thread-preview circle:last-child { fill: #0d211c; stroke: #c84a43; }
|
||||
.connections g.tightening path { stroke-dasharray: 1400; animation: thread-tighten .9s cubic-bezier(.15,.75,.22,1) both; }
|
||||
@keyframes thread-tighten { 0% { stroke-dashoffset: 1400; filter: drop-shadow(0 0 7px #d85a50); } 70% { stroke-dashoffset: 0; stroke-width: 4; } 100% { stroke-dashoffset: 0; stroke-width: 3; } }
|
||||
.thread-tag { position: absolute; z-index: 7; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; border: 0; background: transparent; color: #241d17; cursor: pointer; }
|
||||
.thread-tag i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
|
||||
.thread-tag span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; }
|
||||
.thread-tag:hover i, .thread-tag:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
|
||||
.luggage-tag.luggage-tag { background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; }
|
||||
.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; }
|
||||
.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; }
|
||||
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); }
|
||||
.thread-tag.labelled i { display: none; }
|
||||
.thread-tag-content { display: grid; align-content: start; gap: 7px; height: 108px; padding-top: 10px; overflow: hidden; text-align: left; transition: transform .22s ease; }
|
||||
.thread-tag-content small { padding-bottom: 4px; border-bottom: 1px solid #7e6542; color: #5b472d; font: 600 6px IBM Plex Mono; letter-spacing: .1em; }
|
||||
.thread-tag-content b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; color: #33291c; font: 12px/1.25 Special Elite; }
|
||||
.thread-tag-content em { color: #68472c; font: 600 6px IBM Plex Mono; letter-spacing: .06em; }
|
||||
.thread-tag.labelled.expanded { z-index: 14; transform: translate(-50%, 4px) rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
||||
.thread-tag.labelled.expanded .thread-tag-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
||||
.thread-tag.labelled.expanded .thread-tag-content b { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
||||
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
|
||||
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
@@ -118,9 +130,7 @@ 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; }
|
||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; rotate: -2deg !important; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; z-index: 2; }
|
||||
.evidence-card.note::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; }
|
||||
.evidence-card.note::after { height: 4px; bottom: -1px; background: #745a37; }
|
||||
.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; }
|
||||
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
||||
@@ -225,4 +235,4 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path { animation: none; }.board, .documents-panel { transition: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
|
||||
Reference in New Issue
Block a user