Field notebook: capture NPC lines, tear pages onto the board

A dialogue line gains a "✎ Note this" capture that saves it to a
per-playthrough notebook (migration 035 + notebook endpoints). The inventory
notebook lists captured pages in a handwriting font (Google "Reenie Beanie")
and "✂ Tear to board" drops a page onto the current board as a note exhibit
(reusing addNote), then removes the page. Board notes render handwritten too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:11:15 +02:00
co-authored by Claude Opus 4.8
parent 90ef7b25e8
commit e69558e0e7
9 changed files with 99 additions and 15 deletions
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#071916" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Reenie+Beanie&display=swap" rel="stylesheet" />
<title>GUPI OSINT Board</title>
</head>
<body>
+10
View File
@@ -0,0 +1,10 @@
-- The player's field notebook: lines captured from NPCs during play, per playthrough.
-- A page can later be torn onto the board as a note exhibit.
CREATE TABLE osint.notebook_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
text TEXT NOT NULL,
source_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX notebook_pages_playthrough_idx ON osint.notebook_pages (playthrough_id, created_at);
+17
View File
@@ -516,6 +516,23 @@ app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) =
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
} catch (error) { next(error) }
})
// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard).
app.get('/api/playthroughs/:id/notebook', async (req, res, next) => {
try { res.json(await narrative.notebookPages(String(req.params.id))) }
catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
try {
const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null)
page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' })
} catch (error) { next(error) }
})
app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => {
try {
const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' })
} catch (error) { next(error) }
})
// The phone tool: the directory available on the current node, and dialing a number.
app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
try { res.json(await narrative.phoneDirectory(String(req.params.id))) }
+23
View File
@@ -59,6 +59,9 @@ export interface NarrativeRepository {
listAchievements(playthroughId: string): Promise<string[] | null>
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
@@ -282,6 +285,26 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return { ok: true, earned: (result.rowCount || 0) > 0 }
},
// Field notebook: lines the player captured from NPCs during this playthrough.
async notebookPages(playthroughId) {
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
},
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
const clean = text.trim()
if (!clean) return null
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
const row = (await pool.query<{ id: string }>(
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
return { id: row.id, text: clean }
},
async removeNotebookPage(playthroughId, pageId) {
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
return (result.rowCount || 0) > 0
},
// The phone directory available on the player's current node: the terminals of a
// phone node the current node is wired to. No connected phone node => nobody's listed.
async phoneDirectory(playthroughId) {
+5 -4
View File
@@ -214,8 +214,8 @@ export function App() {
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
}
const addNote = () => {
const content = window.prompt('What do you think this evidence means?')?.trim()
const addNote = (preset?: string) => {
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
if (!content || !caseState) return
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
@@ -543,7 +543,8 @@ export function App() {
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
return <main className="desktop">
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }} onClose={() => setInventoryOpen(false)} />
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
</Suspense>}
<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>
@@ -608,7 +609,7 @@ export function App() {
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
<span />
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
<button onClick={() => addNote()}><NotebookPen size={17}/> NEW NOTE</button>
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
+23 -6
View File
@@ -85,13 +85,13 @@ function ToolRack({ index }: { index: number }) {
return <div ref={stageRef} className="inv-stage" />
}
export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) {
export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) {
const [index, setIndex] = useState(0)
const [active, setActive] = useState<string | null>(null)
if (active) return <div className="inv-tool">
<button className="inv-back" onClick={() => setActive(null)}> TOOLS</button>
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool playthroughId={session?.playthroughId} onTear={onTearToBoard} />}
</div>
return <div className="inv-backdrop">
@@ -108,13 +108,30 @@ export function Inventory({ onClose, session }: { onClose?: () => void; session?
</div>
}
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
function NotebookTool() {
const [text, setText] = useState('')
// Field notebook: the lines captured from NPCs during play. A page can be torn off
// onto the board as a note (when a board tear handler is available).
function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) {
const [pages, setPages] = useState<{ id: string; text: string }[]>([])
useEffect(() => {
if (!playthroughId) return
fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {})
}, [playthroughId])
const remove = async (id: string) => {
if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' })
setPages(list => list.filter(page => page.id !== id))
}
const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) }
return <div className="notebook">
<div className="notebook-page">
<div className="notebook-head">FIELD NOTEBOOK</div>
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
{!pages.length && <p className="notebook-empty">Nothing noted yet. During a conversation, use Note this to jot down what someone says.</p>}
{pages.map(page => <div key={page.id} className="notebook-note">
<p>{page.text}</p>
<div className="notebook-note-actions">
{onTear && <button onClick={() => tear(page)} title="Tear off onto the board"> Tear to board</button>}
<button className="ghost" onClick={() => void remove(page.id)} title="Discard">×</button>
</div>
</div>)}
</div>
</div>
}
+4 -2
View File
@@ -81,7 +81,7 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
// branch, follow a chosen option to the next line or out through its exit terminal.
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
export function DialoguePlayer({ node, onExit, onAward, onCapture, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; onCapture?: (text: string, utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
const onAwardRef = useRef(onAward)
@@ -149,7 +149,9 @@ export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { nod
<div className="dialogue-scrim" aria-hidden />
<div className="dialogue-box">
<div className="dialogue-panel">
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}
{onCapture && !inline && current.utterer === 'npc' && done && <button className="dialogue-capture" title="Copy to notebook" onClick={event => { event.stopPropagation(); onCapture(current.text, current.id) }}> Note this</button>}
</div>
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
{showChoices
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
+2 -1
View File
@@ -103,6 +103,7 @@ export function Play() {
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
onExit={terminalKey => { void advance(terminalKey) }}
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }}
onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} />
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
}
+12 -2
View File
@@ -787,6 +787,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.inv-back:hover { border-color: #cdea6a; }
/* notebook tool (placeholder) */
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }
.notebook-empty { font: 22px/1.3 "Reenie Beanie", cursive; color: #8a7a52; }
.notebook-note { border-bottom: 1px dashed #c8b98f; padding-bottom: 8px; }
.notebook-note p { margin: 0 0 6px; font: 27px/30px "Reenie Beanie", cursive; color: #26356b; }
.notebook-note-actions { display: flex; gap: 8px; }
.notebook-note-actions button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 3px 9px; cursor: pointer; }
.notebook-note-actions button.ghost { background: transparent; border-color: transparent; color: #a08a5a; }
.notebook-note-actions button:hover { border-color: #6a5a3a; }
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
/* A note torn to the board reads as handwriting too. */
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }