diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index 8236913..0f80287 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -153,9 +153,6 @@ test('a cloned Glass Harbor level can be solved without modifying its template', expect(tagAfter).not.toBeNull() expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20) 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() expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50') await page.locator('.tag-style-picker input[value="compact"]').check() @@ -168,9 +165,8 @@ test('a cloned Glass Harbor level can be solved without modifying its template', const solved = await (await request.get(`/api/levels/${playable.id}`)).json() const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver') - expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) })) + expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: 0 })) expect(eliasThread.tagPosition).not.toBe(50) - expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19) 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 }[] diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 7bc7899..171e442 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -626,8 +626,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65)))) const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage' const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50)))) - const lateralLimit = Math.round(10 + (100 - tightness) * .6) - const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0)))) + const tagOffset = 0 await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset) VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset]) } diff --git a/src/App.tsx b/src/App.tsx index d089cc6..b7a2c31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' import { audio } from './audio' -import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' +import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import type { PlaythroughState } from './narrative' @@ -89,6 +89,7 @@ export function App() { const [advancing, setAdvancing] = useState(false) const saveTimer = useRef(undefined) const boardRef = useRef(null) + const initiallyFocusedLevelRef = useRef(null) const fileInputRef = useRef(null) const adminMenuRef = useRef(null) const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1' @@ -180,6 +181,29 @@ export function App() { return () => window.clearTimeout(timer) }, [arrivingExhibitIds]) + useLayoutEffect(() => { + if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return + const board = boardRef.current + const primaryClaim = caseState.exhibits.find(exhibit => exhibit.type === 'claim' && !exhibit.hidden) + if (!board || !primaryClaim) return + const bounds = board.getBoundingClientRect() + if (!bounds.width || !bounds.height) return + + // In portrait the vertical tool rail occupies part of the apparent canvas. + // Centre the claim in the remaining usable board rather than underneath it. + const toolRail = board.parentElement?.querySelector('.board-actions') + const toolBounds = toolRail?.getBoundingClientRect() + const portraitToolInset = toolBounds && toolBounds.height > toolBounds.width * 2 + ? Math.max(0, bounds.right - toolBounds.left + 8) + : 0 + const viewport = { ...caseState.viewport, zoom: clampBoardZoom(Math.max(caseState.viewport.zoom, .85)) } + initiallyFocusedLevelRef.current = caseState.id + setCaseState(current => current?.id === caseState.id ? { + ...current, + viewport: viewportCenteredOnExhibit(viewport, primaryClaim, { width: bounds.width, height: bounds.height }, { right: portraitToolInset }), + } : current) + }, [caseState]) + const update = useCallback((fn: (state: CaseState) => CaseState) => { setCaseState(current => { if (!current) return current @@ -201,7 +225,10 @@ export function App() { const ev = caseState.exhibits.find(e => e.id === id) if (!ev) return setSelected(id) - update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } })) + const bounds = boardRef.current?.getBoundingClientRect() + update(s => ({ ...s, viewport: bounds + ? viewportCenteredOnExhibit(s.viewport, ev, { width: bounds.width, height: bounds.height }) + : s.viewport })) } const extract = (doc: CaseDocument, regionId: string) => { @@ -788,7 +815,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx const pinchDistance = useRef(null) const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null) const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null) - const [expandedThreadTagId, setExpandedThreadTagId] = useState(null) const [draggingThreadTagId, setDraggingThreadTagId] = useState(null) const [draggingWidget, setDraggingWidget] = useState(false) const [trashActive, setTrashActive] = useState(false) @@ -887,7 +913,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx setTrashActive(overTrash) } if (active.kind === 'thread-tag' && boardRef.current) { - if (active.moved) setExpandedThreadTagId(null) const connection = state.connections.find(item => item.id === active.id) const from = connection ? pointForId(connection.fromExhibitId) : undefined const to = connection ? pointForId(connection.toExhibitId) : undefined @@ -895,7 +920,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx const bounds = boardRef.current.getBoundingClientRect() const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom } const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer) - update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) })) + update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: 0 } : item) })) } } else if (active.kind === 'widget') update(s => { const next = moveBoardPoint({ x: active.originX, y: active.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === active.id ? { ...exhibit, ...next } : exhibit) } }) else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: active.originX, y: active.originY }, { x: dx, y: dy }) })) @@ -953,7 +978,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx return
{ const target = e.target as HTMLElement - if (!target.closest('.thread-tag')) setExpandedThreadTagId(null) const emptyBoardDrag = e.button === 0 && !target.closest('.evidence-card, .source-file-widget, .thread-tag, button') if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) } }} @@ -965,7 +989,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return })} {previewOrigin && threadPointer && } - {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return })} + {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, 0); const compact = connection.tagStyle === 'compact'; const dragging = draggingThreadTagId === connection.id; return })} {state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? : null })} @@ -1198,17 +1222,15 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage') const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50) const save = (tag = label) => { - const lateralLimit = threadTagLateralLimit(tightness) - onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, - tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) }) + onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, tagOffset: 0 }) } return
{ event.preventDefault(); save() }}>
{isNew ? 'Add relation tag' : 'Edit red thread'}
RED THREAD · INVESTIGATOR RELATION
{sourceName}{targetName}

What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.

-
TAG PRESENTATION
- +
TAG PRESENTATION
+
{!isNew && }{isNew && }
diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index 7ddd01a..4011111 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -19,6 +19,7 @@ import { threadTagPlacement, timelinePositionPercent, timelineRange, + viewportCenteredOnExhibit, zoomFromWheel, zoomFromPinch, zoomViewportAt, @@ -40,13 +41,14 @@ describe('red thread geometry', () => { it('places and projects tags by percentage along the curve', () => { const from = { x: 0, y: 0 }, to = { x: 200, y: 0 } expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 }) - expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 }) + expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 }) }) - it('reduces lateral tag travel as the thread becomes taut', () => { - expect(threadTagLateralLimit(0)).toBe(70) - expect(threadTagLateralLimit(100)).toBe(10) - expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10) + it('constrains tags to the thread regardless of thread tension', () => { + expect(threadTagLateralLimit(0)).toBe(0) + expect(threadTagLateralLimit(100)).toBe(0) + expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100)).toMatchObject({ x: 100, y: 0, lateralOffset: 0 }) + expect(projectThreadTag({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, { x: 150, y: 80 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 }) }) }) @@ -113,6 +115,24 @@ describe('board coordinate math', () => { expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport) }) + it('centres an exhibit in the usable mobile canvas', () => { + const viewport = viewportCenteredOnExhibit( + { x: 0, y: 28, zoom: .85 }, + { x: 940, y: 360, width: 330, height: 190 }, + { width: 390, height: 658 }, + { right: 64 }, + ) + expect(viewport).toEqual({ x: -776.25, y: -57.75, zoom: .85 }) + expect(viewport.x + (940 + 330 / 2) * viewport.zoom).toBe(163) + expect(viewport.y + (360 + 190 / 2) * viewport.zoom).toBe(329) + }) + + it('rejects invalid zoom when centring an exhibit', () => { + expect(() => viewportCenteredOnExhibit( + { x: 0, y: 0, zoom: 0 }, folder, { width: 390, height: 658 }, + )).toThrow(RangeError) + }) + it('places new exhibits in deterministic open slots', () => { const preferred = { x: 500, y: 400 } expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred) diff --git a/src/boardDomain.ts b/src/boardDomain.ts index 955e558..bd062c9 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -21,17 +21,8 @@ function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardP } } -function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) { - const inverse = 1 - t - return { - x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x), - y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y), - } -} - -export function threadTagLateralLimit(tightness = 65) { - const normalized = Math.max(0, Math.min(100, Number(tightness) || 0)) - return Math.round(10 + (100 - normalized) * .6) +export function threadTagLateralLimit(_tightness = 65) { + return 0 } export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) { @@ -39,12 +30,9 @@ export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = const position = Math.max(5, Math.min(95, Number(positionPercent) || 50)) const t = position / 100 const point = cubicPoint(from, c1, c2, to, t) - const tangent = cubicTangent(from, c1, c2, to, t) - const length = Math.hypot(tangent.x, tangent.y) || 1 - const normal = { x: -tangent.y / length, y: tangent.x / length } const maxLateralOffset = threadTagLateralLimit(tightness) const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0)) - return { x: point.x + normal.x * offset, y: point.y + normal.y * offset, positionPercent: position, lateralOffset: offset, maxLateralOffset } + return { x: point.x, y: point.y, positionPercent: position, lateralOffset: offset, maxLateralOffset } } export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) { @@ -57,13 +45,8 @@ export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: nu const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2 if (distance < bestDistance) { bestDistance = distance; bestT = t } } - const point = cubicPoint(from, c1, c2, to, bestT) - const tangent = cubicTangent(from, c1, c2, to, bestT) - const length = Math.hypot(tangent.x, tangent.y) || 1 - const normal = { x: -tangent.y / length, y: tangent.x / length } const maxLateralOffset = threadTagLateralLimit(tightness) - const lateralOffset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, (pointer.x - point.x) * normal.x + (pointer.y - point.y) * normal.y)) - return { positionPercent: Math.round(bestT * 100), lateralOffset: Math.round(lateralOffset), maxLateralOffset } + return { positionPercent: Math.round(bestT * 100), lateralOffset: 0, maxLateralOffset } } export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) { @@ -126,6 +109,25 @@ export function nextOpenBoardPosition( export interface BoardScreenSize { width: number; height: number } export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number } +/** Centres an exhibit in the usable portion of the board viewport. */ +export function viewportCenteredOnExhibit( + viewport: Viewport, + exhibit: Pick, + screen: BoardScreenSize, + insets: BoardScreenInsets = {}, +): Viewport { + if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive') + const left = Math.max(0, insets.left || 0) + const top = Math.max(0, insets.top || 0) + const usableWidth = Math.max(1, screen.width - left - Math.max(0, insets.right || 0)) + const usableHeight = Math.max(1, screen.height - top - Math.max(0, insets.bottom || 0)) + return { + x: left + usableWidth / 2 - (exhibit.x + exhibit.width / 2) * viewport.zoom, + y: top + usableHeight / 2 - (exhibit.y + exhibit.height / 2) * viewport.zoom, + zoom: viewport.zoom, + } +} + /** * Finds an open board position inside the portion of the canvas the player can * currently see. Screen insets reserve space for board chrome such as the mobile diff --git a/src/styles.css b/src/styles.css index e3565cb..97e1142 100644 --- a/src/styles.css +++ b/src/styles.css @@ -80,19 +80,15 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .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.compact { transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; } +.thread-tag.compact { transform: translate(-4.5px, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; } .thread-tag.compact 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-compact-label { 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.compact:hover i, .thread-tag.compact:focus-visible i { 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 { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, -13px); } .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; } @@ -238,10 +234,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .evidence-card.note.luggage-tag .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; } .evidence-card.note.luggage-tag h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; } .evidence-card.note.luggage-tag p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; } -.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); } -.evidence-card.note.luggage-tag.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); } -.evidence-card.note.luggage-tag.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; } -.evidence-card.note.luggage-tag.selected h3 { font-size: 7px; } +.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; filter: drop-shadow(7px 9px 5px #0007); } .board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; } .board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; } .board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }