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
+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; }