This document summarises the "Red String" feature implemented during this session, which allows users to visually link sticky notes together with red strings on the cork board — similar to a detective evidence board.
- Feature Overview
- Architecture
- Data Model
- Interaction Flow
- RedStringLayer Component
- StickyNote Pin Changes
- DashboardPage Orchestration
- CorkBoard Changes
- Performance Optimisation
- Visual Design
- File Inventory
Users can click and hold on the coloured pin at the top of any sticky note, drag to another note's pin, and release to create a persistent red string connection between them. Connections are rendered as SVG paths with a catenary (hanging string) curve. Hovering over an established string reveals a delete button at its midpoint.
Key behaviours:
- Click-and-drag from pin to pin to create a connection.
- Dashed red preview line follows the cursor while linking is in progress.
- All other pins pulse and scale up to signal they are valid drop targets.
- Duplicate connections between the same pair of notes are prevented.
- Connections are automatically removed when either linked note is deleted.
- Strings follow notes in real-time during drag at native frame rate.
- Hover over a string to highlight it; click to delete.
The feature is coordinated across four components with DashboardPage as the state owner:
DashboardPage (state: connections[], linkingFrom, linkMousePos)
├── CorkBoard (boardRef forwarded to corkboard-surface div)
│ ├── RedStringLayer (SVG overlay, rAF loop for fluid rendering)
│ └── StickyNote[] (pins with data-pin-note-id attributes)
- DashboardPage owns connection state and linking lifecycle (mousedown / mousemove / mouseup).
- CorkBoard exposes a
boardRefso the SVG layer can convert screen coordinates to board-relative positions. - RedStringLayer renders all strings via an absolutely-positioned SVG element and reads pin positions directly from the DOM each frame.
- StickyNote pins are made interactive with
onMouseDownhandlers and visual feedback.
A new TypeScript interface was added to Source/frontend/src/types/index.ts:
export interface NoteConnection {
id: string; // e.g. "conn-1"
fromNoteId: string;
toNoteId: string;
}Connections are currently stored in React component state within DashboardPage (frontend-only, no backend persistence). Backend API support can be added in a future iteration.
- Mouse down on pin —
onPinMouseDownfires, settinglinkingFromto the source note ID. Document-levelmousemoveandmouseuplisteners are attached. - Mouse move —
mousemoveupdateslinkMousePoswith the board-relative cursor position. TheRedStringLayerrAF loop picks this up and draws a dashed preview line from the source pin to the cursor. - Hover over target pin — Target pins display a scale + pulse animation via the
isLinkingprop and CSSgroup-hoverutilities. - Mouse up over a pin —
document.elementFromPointresolves the element under the cursor; if it matches a[data-pin-note-id]element for a different note, a newNoteConnectionis created. Duplicates are rejected. - Mouse up elsewhere — Linking is cancelled; the preview line disappears.
- Delete a note — All connections involving that note are removed from state.
- Delete a connection — Hover over a string to reveal a delete icon at the midpoint. Click the string or the icon to remove it.
File: Source/frontend/src/components/dashboard/RedStringLayer.tsx
A new component that renders an SVG overlay covering the entire cork board.
| Prop | Type | Description |
|---|---|---|
connections |
NoteConnection[] |
Established connections to render |
linkingFrom |
string | null |
Source note ID during active linking |
mousePos |
{ x, y } | null |
Board-relative cursor position during linking |
boardRef |
RefObject<HTMLDivElement | null> |
Reference to the board container |
onDeleteConnection |
(id: string) => void |
Callback to remove a connection |
- The SVG element is positioned
absolute inset-0withpointer-events: noneatz-index: 5. - Each connection renders two
<path>elements: an invisible wide hit-area (14px stroke,pointer-events: stroke) and a visible red 2px string. - A persistent
<path data-link-line>element is toggled visible/hidden for the in-progress linking preview. - A
<foreignObject>with an X icon appears at the string midpoint on hover.
Strings use SVG quadratic bezier paths (Q command). The control point is offset below the midpoint proportionally to horizontal distance, capped at 40px droop:
const droop = 40 * Math.min(1, horizontalDistance / 300);
// Path: M fromX fromY Q midX (midY + droop) toX toYFile: Source/frontend/src/components/dashboard/StickyNote.tsx
| Prop | Type | Description |
|---|---|---|
onPinMouseDown |
(noteId: string) => void |
Fires when pin is pressed to start linking |
isLinking |
boolean |
True when any note on the board is being linked |
- Added
data-pin-note-id={note.id}attribute for DOM-based position queries. - Added
onMouseDownhandler withstopPropagationandpreventDefaultto avoid triggering drag or edit mode. - Added a larger invisible hit-area (
absolute -inset-2) around the 16px pin. - Pin scales up on hover (
group-hover/pin:scale-150) whenonPinMouseDownis provided. - During active linking (
isLinking), all pins pulse (animate-pulse) and show a red ring on hover.
File: Source/frontend/src/pages/DashboardPage.tsx
const [connections, setConnections] = useState<NoteConnection[]>([]);
const [linkingFrom, setLinkingFrom] = useState<string | null>(null);
const [linkMousePos, setLinkMousePos] = useState<{ x, y } | null>(null);
const boardRef = useRef<HTMLDivElement>(null);| Handler | Purpose |
|---|---|
handlePinMouseDown(noteId) |
Sets linkingFrom, starts linking mode |
handleDeleteConnection(id) |
Removes a connection from state |
A useEffect hook activates when linkingFrom is set:
mousemove— UpdateslinkMousePoswith board-relative coordinates.mouseup— Usesdocument.elementFromPoint+closest('[data-pin-note-id]')to detect a target pin. Creates a connection if valid; otherwise cancels. Cleans up both listeners on teardown.
handleDelete was extended to filter out any connections that reference the deleted note.
File: Source/frontend/src/components/dashboard/CorkBoard.tsx
- Added an optional
boardRefprop (RefObject<HTMLDivElement | null>). - The ref is attached to the
corkboard-surfaceinner div via a callback ref pattern to satisfy TypeScript's strict ref typing. - This allows
RedStringLayerandDashboardPageto compute board-relative coordinates from screen positions.
The first implementation used a React state counter (dragTick) bumped on every react-draggable onDrag event to trigger position recalculation. This required two full React re-render cycles per frame:
onDrag → setDragTick → re-render → useEffect → rAF → DOM read → setPinPositions → re-render
The RedStringLayer runs a persistent requestAnimationFrame loop that:
- Reads pin positions directly from the DOM via
getBoundingClientRect()on[data-pin-note-id]elements. - Writes computed path
dattributes directly onto SVG<path>elements viasetAttribute().
This completely bypasses React's rendering pipeline for position tracking. Latest connections, linkingFrom, and mousePos values are read from refs (synced from props) so the loop has no React dependencies.
Result: Strings update at native display refresh rate (60fps+) with zero React overhead during drag. The dragTick state, handleNoteDrag handler, and onDrag prop were removed as they became unnecessary.
| Element | Style |
|---|---|
| String colour | #dc2626 (Tailwind red-600) |
| String width | 2px (3px on hover) |
| String opacity | 0.85 (1.0 on hover) |
| String shape | Quadratic bezier with up to 40px catenary droop |
| In-progress line | Dashed (stroke-dasharray: 6 4), 0.6 opacity |
| Pin hover (linking active) | scale-150, animate-pulse, red ring |
| Delete button | 20px red circle with white X icon at string midpoint |
| Hit-area | 14px invisible stroke for easy hover targeting |
| File | Purpose |
|---|---|
Source/frontend/src/components/dashboard/RedStringLayer.tsx |
SVG overlay for rendering connections and linking preview |
| File | Changes |
|---|---|
Source/frontend/src/types/index.ts |
Added NoteConnection interface |
Source/frontend/src/components/dashboard/CorkBoard.tsx |
Added boardRef prop, callback ref bridge |
Source/frontend/src/components/dashboard/StickyNote.tsx |
Interactive pin with data-pin-note-id, onPinMouseDown, isLinking styling |
Source/frontend/src/pages/DashboardPage.tsx |
Connection state, linking lifecycle, document listeners, connection cleanup |